AWS Lambda

AWS Lambda Hono Validation esbuild

Hono で動作し esbuild で単一のデプロイアーティファクトにバンドルされる AWS Lambda 関数。同梱のアプリは最初に必要となる 2 つのパターン — JSON ボディの POST と パスパラメータの GET — を示しており、いずれも API Gateway に適合する hono/aws-lambda アダプター経由で配線されている。HTTP API トリガーと組み合わせれば、ウィザードを離れることなく 動作するエンドポイントが得られる。

ビルドパイプラインは意図的に単発である: pnpm build は ReScript をコンパイルし、続いて src/Server.res.mjsdist/index.mjs (ESM、Node ターゲット) に esbuild する。zip にしてアップロードしてもよいし、SAM / CDK / Terraform に組み込んでもよい — アーティファクトは同じである。

生成内容

my-project/
├── rescript.json
├── package.json
├── src/
│   ├── Server.res               # Hono app, exposes `let handler = HonoLambda.handle(app)`
│   ├── HonoLambda.res           # bindings over hono/aws-lambda's `handle`
│   ├── Hono.res                 # Hono bindings
│   ├── Validation.res           # zod or sury — selected in the wizard
│   └── __tests__/Server.test.mjs # vitest smoke import
├── README.md                    # API / Deploy / Bundling Strategy / DynamoDB Recipe
├── LICENSE                      # MIT, holder = project name
├── .nvmrc                       # Node 24 (matches the Lambda runtime)
├── .gitignore                   # node_modules, dist/, *.zip, .aws-sam/
├── .editorconfig                # 2-space indent, LF line endings
└── .github/
    ├── dependabot.yml           # weekly npm updates
    └── workflows/ci.yml         # install + rescript build + bundle + vitest

ウィザードオプション

オプション

効果

Project name

npm の name、ライセンス保有者、README のデプロイスニペットに展開される --function-name (およびバンドルセクションの --layer-name) になる

Package manager

npm / pnpm / yarn / bun。packageManager フィールド、README の install/run コマンド、CI のキャッシュキーに影響する

Validation library

zodsurysrc/Validation.res を選択し、対応する依存を追加する。両者は parseCreateOrderPayload を公開するため、ルートハンドラは分岐する必要がない

主要な依存

パッケージ

用途

バージョン

rescript

ReScript コンパイラ

TemplateVersions.RESCRIPT

@rescript/core

標準ライブラリ

TemplateVersions.RESCRIPT_CORE

@rescript/runtime

コンパイル済み .res.mjs がインポートするランタイムスタブ

TemplateVersions.RESCRIPT_RUNTIME

hono

hono/aws-lambda アダプター付きの HTTP ルーター

TemplateVersions.HONO

zod または sury

リクエストボディのバリデーション (ウィザードで選択)

TemplateVersions.ZOD / SURY

esbuild (dev)

src/Server.res.mjsdist/index.mjs にバンドルする

TemplateVersions.ESBUILD

vitest (dev)

スモークテストランナー

TemplateVersions.VITEST

@vitest/coverage-v8 (dev)

test:coverage 向けのカバレッジプロバイダ

TemplateVersions.VITEST_COVERAGE_V8

テンプレートは @types/aws-lambdaaws-lambda-ric を事前インストールしない。Hono のアダプター (hono/aws-lambda) が返す関数のシグネチャは API Gateway が期待するハンドラの形に合致するため、同梱のルートには別途のハンドラ型は不要である。APIGatewayProxyEventV2Context を扱う手書きハンドラを書く場合は、後から @types/aws-lambda を追加するとよい。

主要なファイル

src/Server.res

Hono アプリと、AWS Lambda が呼び出し時に解決する名前付き handler エクスポート。トップレベルの let バインディングは自動的に ESM の名前付きエクスポートになるため、let handler = HonoLambda.handle(app) は ReScript における export const handler = ... の等価物である:

type createOrderResponse = {orderId: string, productId: string, quantity: int}

let app = Hono.createApp()

app->Hono.get("/", ctx => ctx->Hono.text("Lambda + Hono + ReScript"))

app->Hono.get("/orders/:id", ctx => {
  let id = ctx->Hono.req->Hono.paramAt("id")
  ctx->Hono.json({"orderId": id, "status": "pending"})
})

app->Hono.post("/orders", async ctx => {
  let raw = await ctx->Hono.req->Hono.jsonBody
  switch Validation.parseCreateOrderPayload(raw) {
  | Error(msg) => ctx->Hono.status(400)->Hono.json({"error": msg})
  | Ok(payload) =>
    let response: createOrderResponse = {
      orderId: "ord_" ++ Date.now()->Float.toString,
      productId: payload.productId,
      quantity: payload.quantity,
    }
    ctx->Hono.status(201)->Hono.json(response)
  }
})

let handler = HonoLambda.handle(app)

これを (%%raw ではなく) 実際の let バインディングで行うことで、ReScript コンパイラが HonoLambda の import を出力することが保証される — その上で esbuild が アダプターを dist/index.mjs にバンドルする。

src/HonoLambda.res

Hono アダプターに対する 2 行のバインディング — 一目で読み切れる小ささだが、テンプレートが必要とする API Gateway 関連の知識はこれですべてカバーできる:

type lambdaEvent
type lambdaResult
type handler = lambdaEvent => promise<lambdaResult>

@module("hono/aws-lambda") external handle: Hono.app => handler = "handle"

このアダプターは API Gateway からの REST API (v1) と HTTP API (v2) の両方のイベントを受け取り、Hono がルーティングできる fetch 互換のリクエストに変換する。

src/Validation.res

両バリアントは parseCreateOrderPayload: JSON.t => result<createOrderPayload, string> を公開する。ルートハンドラは Error で 400 を返し、Ok で処理を続行する — これはウィザードが同梱するすべてのサーバーテンプレートで使われているパターンであり、マッスルメモリが活かせる。より広いエコシステム (zod-to-openapi、hono/zod-openapi) を取りたいなら zod を、ReScript ネイティブの使い心地と小さいランタイムフットプリントを優先するなら sury を選ぶとよい。

src/__tests__/Server.test.mjs

モジュールがロードされることを検証する 1 行のスモークテスト:

await expect(import("../Server.res.mjs")).resolves.toBeDefined();

CI ではバンドルステップと並んで実行されるため、壊れたハンドラは早期に失敗する。実際の Lambda 統合テストでは、aws-lambda-mock-contextsam local invoke で合成した API Gateway イベントを使い、バンドル済みの dist/index.mjs を駆動する。

エンドポイント

エンドポイント

メソッド

説明

/

GET

ヘルスチェック (text/plain)

/orders/:id

GET

ID (パスパラメータ) でオーダーを検索する

/orders

POST

{ productId, quantity } (JSON ボディ) からオーダーを作成する

リクエスト/レスポンスの形状

POST /orders
Content-Type: application/json

{ "productId": "sku-42", "quantity": 3 }
HTTP/1.1 201 Created
Content-Type: application/json

{ "orderId": "ord_1714142512345", "productId": "sku-42", "quantity": 3 }
GET /orders/ord_1714142512345
HTTP/1.1 200 OK
Content-Type: application/json

{ "orderId": "ord_1714142512345", "status": "pending" }

ボディが欠落または不正な場合は 400 を返す:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{ "error": "Validation failed" }

npm スクリプト

スクリプト

説明

build

rescript && pnpm bundle — ReScript をコンパイルしてからバンドルする

bundle

esbuild src/Server.res.mjs --bundle --platform=node --outfile=dist/index.mjs --format=esm

test

vitest run — スモークスイートを実行する

test:coverage

vitest run --coverage — 同じだが v8 カバレッジレポートを付与する

res:build

rescript — 単発のコンパイル

res:dev

rescript -w — 保存ごとに再コンパイル

res:clean

rescript clean — 生成された .res.mjs を削除する

build スクリプトは 2 つのステップを && で連結する (パッケージマネージャのプレフィックスは ウィザードの選択から展開される)。アップロードするアーティファクトは dist/index.mjs である。

デプロイ手順

ビルドとアップロード

pnpm build                        # rescript && esbuild → dist/index.mjs
cd dist && zip lambda.zip index.mjs
aws lambda update-function-code \
  --function-name my-project \
  --zip-file fileb://lambda.zip

Set the function's Handler to index.handler and Runtime to Node.js 24. Use API Gateway HTTP API as the trigger unless you need a feature only REST API offers.

バンドルサイズの逃げ道

デフォルトのバンドルは、コールドスタートのレイテンシを下げるためにすべての依存をインライン化する。アーティファクトが 50 MB の直接アップロード上限 (展開後 250 MB) に近づいてきたら、以下のいずれかを採用する:

Lambda 提供の依存を external としてマークする

AWS SDK v3 (@aws-sdk/*) は Node.js Lambda ランタイムにプリインストールされている。これを除外するとコマンドあたり数 MB 削減できる:

esbuild src/Server.res.mjs --bundle --platform=node \
  --outfile=dist/index.mjs --format=esm \
  --external:@aws-sdk/* --external:aws-sdk

--external でマークするものは実行時にすでに存在している必要がある — Lambda が同梱しているか、Lambda Layer で公開しているかのいずれかである。

Lambda Layers

大きなネイティブ依存 (Sharp、Prisma エンジン、カスタムバイナリ) は、Layer として一度公開し、バンドルから除外する:

aws lambda publish-layer-version \
  --layer-name my-project-deps \
  --compatible-runtimes nodejs24.x \
  --zip-file fileb://layer.zip

aws lambda update-function-configuration \
  --function-name my-project \
  --layers arn:aws:lambda:<region>:<account>:layer:my-project-deps:1

Layer は実行環境ごとにキャッシュされるため、コールドスタートのコストは呼び出しごとではなく コンテナごとに一度だけ発生する。

Tree-shaking とソースマップ

本番ビルドには --tree-shaking=true --minify --sourcemap を追加する。ソースマップはデプロイアーティファクトの外に残る (dist/index.mjs.map) — Lambda の zip を膨らませることなく、シンボリケート済みのスタックトレースのために APM (Datadog、Sentry、CloudWatch RUM) にアップロードできる。

DynamoDB レシピ (簡易版)

同梱の README には完全な DynamoDB 統合が記載されている。短縮版は以下の通り:

pnpm add @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
type docClient
@module("@aws-sdk/lib-dynamodb") @new
external makeClient: 'opts => docClient = "DynamoDBDocumentClient"
@send external send: (docClient, 'command) => promise<'result> = "send"

対象テーブルに対して Lambda の IAM ロールに dynamodb:PutItem / dynamodb:GetItem を付与する。@aws-sdk/* をプリインストールする Node ランタイムを使い続けるなら、これらを --external でマークしてアーティファクトをスリムに保つ。

ローカルで試す

同梱のルートは、Hono を Node 上で起動し Lambda であるかのように振る舞わせることで、AWS アカウントなしでテスト可能である。使い捨てのエントリファイルを追加する:

// src/Local.res
HonoNodeServer.serve({fetch: Server.app->HonoNodeServer.honoFetch, port: 3000})

node src/Local.res.mjs で起動し、http://localhost:3000 に curl をかける:

curl -X POST http://localhost:3000/orders \
  -H 'Content-Type: application/json' \
  -d '{"productId":"sku-42","quantity":3}'
# => 201 {"orderId":"ord_…","productId":"sku-42","quantity":3}

curl http://localhost:3000/orders/ord_42
# => 200 {"orderId":"ord_42","status":"pending"}

Lambda に出荷する Hono アダプター (hono/aws-lambda) とローカルで使う Node サーバー (@hono/node-server) はどちらも同じ app インスタンスをラップしている。そのため、両端のランタイムアダプター以外は 挙動が同じである。これはデプロイの往復コストを払う前にルートを反復改善する最も安上がりな方法である。

完全な Lambda 忠実度を得るには AWS SAM を使う:

sam local invoke MyFunction -e events/post-order.json

sam local invoke は本番の Lambda ランタイムと一致するコンテナ内で、バンドル済みの dist/index.mjs を起動する。コールドスタートの挙動、環境変数、IAM のなりすましがすべてクラウドの動作に一致する。

2日目以降のレシピ

プロジェクトを開いた後の ReScript 側のエディタワークフローについては、機能概要 を参照してほしい。

補足

  • 初期状態で API Gateway 適合。 hono/aws-lambda は REST API (v1) と HTTP API (v2) 両方のイベント形状を受け付けるため、関数をどちらのトリガーに配線してもコード変更なしで動作する。HTTP API の方が安く高速である — REST API でしか得られない機能 (mTLS、ゲートウェイでのリクエスト検証など) が必要でない限り、こちらを選ぶ。

  • Single-artifact bundle. pnpm build produces dist/index.mjs — every dependency inlined, ESM, Node target. Zip it (cd dist && zip lambda.zip index.mjs) and aws lambda update-function-code it. The function's Handler setting is index.handler; the Runtime is Node.js 24.

  • 50 MB のアップロード上限に注意。 デフォルトのバンドルは、コールドスタートのレイテンシを下げるために 全依存をインライン化するが、新しい依存ごとにアーティファクトは大きくなる。README の Bundling Strategy セクションでは 3 つの逃げ道を解説している: Lambda 提供の依存を --external でマークする (AWS SDK v3 は Node ランタイムにプリインストール済み — 除外すれば数 MB 削減できる)、大きなネイティブ依存を Lambda Layer として公開する (Sharp、Prisma エンジン)、本番では tree-shaking + minify + sourcemap を有効にする。

  • DynamoDB レシピは README に記載済み。 同梱の DynamoDB Recipe セクションでは、@aws-sdk/lib-dynamodb (DynamoDBDocumentClientsend) に対する最小限の ReScript バインディングを示し、Lambda の IAM ロールに dynamodb:PutItem / dynamodb:GetItem を付与することを促している。pnpm add @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb でインストールする — プリインストールされていないランタイムを使っている場合は これらを --externalしない こと。

  • handler を実際の let バインディングにしている理由がある。 ReScript コンパイラはモジュールが実際に参照されたときのみ HonoLambda の import を出力する。let handler = HonoLambda.handle(app) と定義することで import は生き残るが、%%raw("export const handler = ...") に切り替えると、サイレントにバンドルから削除されてしまう。

  • スモークテストはモジュールのロードのみを検証する。 src/__tests__/Server.test.mjsawait import("../Server.res.mjs") を実行し、それが解決することをアサートする。実際の Lambda 統合テストでは、aws-lambda-mock-context や SAM の sam local invoke で合成した API Gateway イベントを使い、バンドル済みの dist/index.mjs を駆動する。

  • dist/*.zip.aws-sam/ は gitignore 済み。 ビルド成果物と SAM CLI の作業状態はリポジトリに入らない。

  • Node 24 is mandatory. engines.node is >=24, .nvmrc says 24, and the README's Deploy section pins the Lambda runtime to Node.js 24. The Layer publish snippet interpolates nodejs24.x from the same source — bumping TemplateVersions.NODE_MAJOR updates all three locations atomically.

  • reserved concurrency / provisioned concurrency は未設定。 同梱のテンプレートは オンデマンドを前提としている。プロビジョンドコンカレンシーを必要とするほどレイテンシに敏感なワークロードの場合は、Lambda コンソール (または SAM/CDK/Terraform) で設定する — これはバンドルには影響しない。

  • コールドスタートは重要。 ReScript のコンパイル出力と Hono はどちらも小さい (hono は minzip で 約 14 KB) ため、コールドスタートは V8 のウォームアップと、モジュールロード時にインスタンス化した AWS SDK クライアントが支配する。SDK クライアントの構築はハンドラのクロージャの外に移し、同一コンテナ内の呼び出しを跨いで温かく保つ。一方、リクエストスコープの状態はハンドラ内に保持する。

  • バンドリングは意図的であり、オプションではない。 node src/Server.res.mjs を直接実行しても Lambda としては動かない — Lambda は名前付き ESM エクスポートを呼び出し、ReScript コンパイラが出力する相対 import は index.mjs だけが切り出されてしまうと壊れる。常に esbuild の出力をアップロードし、生の .res.mjs ファイルをアップロードしてはならない。

  • CI はビルドとテストの両方を実行する。 同梱の .github/workflows/ci.ymlpnpm build (ReScript のコンパイルと esbuild のバンドルを実行) を呼び出した後、pnpm test を実行する。バンドルの失敗 (例: @module(...) バインディングの忘れ、依存の欠落) は マージをブロックする — 知りたいタイミングそのものである。