この記事は、ニフティグループ Advent Calendar 2024 7日目の記事です。
はじめに
こんにちは、会員システムグループでエンジニアをしている山田です。
皆様、CloudFront Functionsは使っていますでしょうか。
CloudFrontでエッジ処理を行いたい場合、Lambda@EdgeもしくはCloudFront Functionsのいずれかを使用することになります。CloudFront Functionsはランタイムの強い制限を代償に、Lambda@Edge比で料金が約1/6と、安価に使用することができます。ニフティ社内でも静的サイトでのリダイレクト処理などに活用しています。
軽い処理であるとはいえ、継続的な運用を行うには型チェックやテストの恩恵を受けたいものです。
本記事はそのための環境をどう整えるか、という内容になります。
結論
結論から先に述べると
- TypeScriptだけで良い場合: tscでトランスパイルする
- テストもしたい場合: バンドラーでバンドル後、正規表現でexportを除去する
という形になります。
前提
前提として、CloudFront FunctionsはサーバサイドJavaScriptではあるものの、Node.jsではないランタイムで動作します。このため、以下のような文法上の制約があります。(JavaScript 2.0ランタイムの場合)
- ECMAScript 5ベース
- const/let、async/awaitなど一部のES6文法にも対応
- ES Modules非対応
- CommonJS向けのexport構文がエラーとなる
module.exports
はmoduleがundefinedのためエラー
- 1ファイルのみデプロイ可能
これらの制約を満たすように作る必要があります。
対応方法
型チェックのみで良い場合
型チェックができればよい場合は、TypeScriptの導入のみで良いです。
1 |
pnpm add -D typescript |
tsconfigの設定は以下のようにします。
1 2 3 4 5 6 7 8 9 10 |
{ "compilerOptions": { "target": "es5", ... "baseUrl": "./src", "outDir": "./dist" }, "include": ["**/*.ts"], "exclude": ["node_modules", "dist"] } |
targetはES5にしておきます。ES6文法の多くはCloudFront Functionsでも使用可能ですが、一部非対応の文法を使用しても気づかないため、ES5にしておくのが無難でしょう。
次にCloudFront Functions用の型定義を用意します。npmパッケージとして公開されているものがあるため、これを使います。手元の環境では型情報をうまく参照できなかったため、内容をコピーして利用しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
declare namespace AWSCloudFrontFunction { interface Event { version: '1.0'; context: Context; viewer: Viewer; request: Request; response: Response; } interface Context { distributionDomainName: string; distributionId: string; eventType: 'viewer-request' | 'viewer-response'; requestId: string; } interface Viewer { ip: string; } interface Request { method: string; uri: string; querystring: ValueObject; headers: ValueObject; cookies: ValueObject; } interface Response { statusCode: number; statusDescription?: string; headers?: ValueObject; cookies?: ResponseCookie; } interface ValueObject { [name: string]: { value: string; multiValue?: Array<{ value: string; }>; }; } interface ResponseCookie { [name: string]: { value: string; attributes: string; multiValue?: Array<{ value: string; attributes: string; }>; }; } } |
CloudFront Functionsのソースには上記の型を指定します。
1 2 3 4 5 |
function handler( event: CloudFrontFunction.Event ): AWSCloudFrontFunction.Request | AWSCloudFrontFunction.Response { ... } |
あとはtscでコンパイルすればJavaScriptファイルが得られます。型チェックのみを行う場合は別途コマンドを用意しておくとよいでしょう。
1 |
tsc |
テストもしたい場合
テストも行いたい場合、テスト対象関数をexportする必要が出てきます。またファイルを分割したくなることもあるでしょう。このため、基本的にバンドラを使うことになります。
しかしながら、バンドラの対応するいずれの出力形式でも問題を抱えています。
- ESM形式: handlerのexportが必須、
export ~
が文法エラー - CJS形式: handlerのexportが必須、
module.exports = ~
がmodule変数の未定義によりエラー - IIFE形式: 関数定義が大きく変わる
ESM、CJS形式ではexportされていない関数は不要と判断され、バンドル後の結果に残らなくなります。一方でexportすると出力結果にexportの文法が残るため、エラーを引き起こしてしまいます。これらはesbuildのissueでも議論されていますが、いずれもHackyな回避策となっています。
そこで、ESM形式で出力したものからexportを除去するというアプローチを行います。
バンドラとして今回はtsupを使用します。
1 |
pnpm add -D tsup |
以下のように設定を行います。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import { defineConfig } from 'tsup'; export default defineConfig({ entry: [ // 出力したいhandlerがあるファイル群 ], format: 'esm', platform: 'node', splitting: false, sourcemap: false, minify: false, clean: true, }); |
entryは複数指定できるため、複数の関数ファイルを一括して生成することが可能です。
またビルド後のファイルからexportを取り除くスクリプトを用意しておきます。
(コードによっては正規表現を調整する必要があるかもしれません)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
/* ビルド後のファイルを加工するスクリプト usage: node scripts/postbuild.js {build_dir} CloudFront Functionsはexportを解釈できないため、ビルド後のファイルからexportを削除する */ import * as fs from 'node:fs'; import * as path from 'node:path'; // export文を判別する正規表現 const regex = /export\s*{[^}]*};?/gs; const target = process.argv[2]; const targetPath = path.resolve(target); if (!fs.statSync(targetPath).isDirectory()) { console.error('Target is not a directory'); process.exit(1); } const files = fs.readdirSync(targetPath); for (const file of files) { const filePath = path.join(targetPath, file); const stats = fs.statSync(filePath); if (stats.isDirectory()) { continue; } const ext = path.extname(filePath); if (ext !== '.js') { continue; } console.log('Removing export statement from', filePath); const fileContents = fs.readFileSync(filePath, { encoding: 'utf-8' }); const newContents = fileContents.replace(regex, ''); fs.writeFileSync(filePath, newContents); } |
あとはビルド後にこのスクリプトを実行するようにしておけばOKです。
1 |
tsup && node postbuild.js ./dist |
まとめ
CloudFront FunctionsをTypeScript・テストに対応した環境で書くための方法について解説しました。
どうしてもハック感のある方法を取らざるを得ないのですが、行っていることはシンプルなので今のところこれで運用することができています。テストにも対応できたことで、リダイレクトルール変更時のデグレ防止にも役立っています。
CloudFront Functionsの中身が複雑になってきて、動作の担保に不安がある方は試してみてはいかがでしょうか。
次回はnunaさんの記事です。お楽しみに。