@nifrajs/aws-lambda 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nifra Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @nifrajs/aws-lambda
2
+
3
+ AWS Lambda adapter for nifra - API Gateway HTTP API (payload v2) and Lambda Function URLs, buffered or streaming, with post-decode body limits and single-site header merging. Dependency-free.
4
+
5
+ Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework - one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ bun add @nifrajs/aws-lambda
11
+ ```
12
+
13
+ ## Use
14
+
15
+ ```ts
16
+ // handler.ts - one app per container, built at module scope
17
+ import { handle, streamHandle, type LambdaEnv } from "@nifrajs/aws-lambda"
18
+ import { server } from "@nifrajs/core"
19
+
20
+ const app = server<LambdaEnv>()
21
+ .get("/", (c) => c.json({ ip: c.clientIp, requestId: c.env.context?.awsRequestId }))
22
+
23
+ export const handler = handle(app) // API Gateway HTTP API (v2) + Function URLs
24
+ // or, on a Function URL with InvokeMode: RESPONSE_STREAM:
25
+ // export const handler = streamHandle(app)
26
+ ```
27
+
28
+ How it holds the line:
29
+
30
+ - **Post-decode body limits.** The body is decoded (base64 or UTF-8) and its real `byteLength` checked against `maxBodyBytes` (default 1,000,000 - match your app's setting if you changed it) *before* a `Request` is constructed. The event's own length claims are never trusted; over the limit answers a flat `413`.
31
+ - **One header merge site.** Request headers come from the v2 `headers` map plus the separate `cookies` array in exactly one function; the array is canonical, so a `cookie` key smuggled into `headers` never reaches the app. Response `Set-Cookie` values travel in the result's `cookies` array, one entry each - never comma-joined.
32
+ - **Base64 honesty.** Response `isBase64Encoded` is decided by a strict UTF-8 decode of the actual bytes, never by content-type guessing. Binary round-trips exactly; text ships as text.
33
+ - **Client IP.** `event.requestContext.http.sourceIp` feeds core's client-IP seam; `X-Forwarded-For` trust stays where it belongs, in the app's `clientIp` server option.
34
+ - **Flat errors.** Uncaught failures collapse to the same `{"ok":false,"error":"internal_error"}` 500 as every other nifra runtime - the event is never echoed.
35
+ - **Freeze-safe `waitUntil`.** Background work settles before the handler returns, so Lambda's container freeze cannot strand it.
36
+
37
+ The event and invocation context ride on `c.env.event` / `c.env.context` (type your app `server<LambdaEnv>()`). REST APIs (payload v1) and ALB events are out of scope; API Gateway does not support response streaming - `streamHandle` is for Function URLs.
38
+
39
+ ## Docs
40
+
41
+ - Reference: <https://nifra.dev/docs>
42
+ - AI-readable: <https://nifra.dev/llms.txt>
43
+
44
+ MIT
45
+
46
+ ## For AI agents
47
+
48
+ Start with [`LLM.md`](./LLM.md) - this package's contract card (the exports you call + its footguns),
49
+ one cheap read instead of the whole corpus. For the wider framework: the repo's
50
+ [`AGENTS.md`](../../AGENTS.md) is the copy-paste quick reference, and
51
+ [`llms-full.txt`](../../llms-full.txt) is the full machine-readable corpus. Run `nifra check` as the
52
+ done-gate, or `nifra mcp` to give the agent live project tools.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * AWS Lambda adapter for nifra - runs an app behind API Gateway HTTP APIs (payload format v2) and
3
+ * Lambda Function URLs (the same v2 shape), buffered via {@link handle} or streamed via
4
+ * {@link streamHandle}. REST APIs (payload v1) and ALB events are out of scope.
5
+ *
6
+ * Security posture, by construction:
7
+ * - Request headers are assembled from the event in exactly one place ({@link requestHeaders}):
8
+ * the `headers` map plus the separate `cookies` array. There is no second merge site, and the
9
+ * `cookies` array is canonical - a `cookie` key smuggled into `headers` never reaches the app.
10
+ * - The body is decoded first (base64 or UTF-8) and its real `byteLength` checked against
11
+ * `maxBodyBytes` *before* a `Request` is constructed. The event's own claims about length -
12
+ * `content-length` or the encoded size - are never trusted.
13
+ * - Response `isBase64Encoded` is decided by what the bytes actually are (strict UTF-8 decode),
14
+ * never by content-type guessing.
15
+ * - Uncaught errors become the same flat `internal_error` 500 as every other nifra runtime; the
16
+ * event is never echoed.
17
+ *
18
+ * The adapter is dependency-free: it accepts anything with a fetch-shaped `fetch` method and
19
+ * declares the Lambda event/`awslambda` shapes structurally.
20
+ */
21
+ /** Platform fields the adapter feeds into `app.fetch` (mirrors nifra core's `Platform`). */
22
+ export interface LambdaPlatform {
23
+ readonly env: LambdaEnv;
24
+ readonly clientIp?: string | undefined;
25
+ readonly waitUntil?: (promise: Promise<unknown>) => void;
26
+ }
27
+ /** Anything with a fetch-shaped entry point - a nifra `server()` app satisfies this. */
28
+ export interface FetchHandler {
29
+ fetch(request: Request, platform?: LambdaPlatform): Response | Promise<Response>;
30
+ }
31
+ /**
32
+ * The slice of an API Gateway HTTP API / Function URL payload-v2 event the adapter reads.
33
+ * Extra fields pass through untouched on `c.env.event`.
34
+ */
35
+ export interface LambdaEvent {
36
+ readonly rawPath?: string;
37
+ readonly rawQueryString?: string;
38
+ readonly headers?: Readonly<Record<string, string | undefined>>;
39
+ /** Payload v2 delivers cookies here, not in `headers` - this array is the canonical source. */
40
+ readonly cookies?: readonly string[];
41
+ readonly body?: string | null;
42
+ readonly isBase64Encoded?: boolean;
43
+ readonly requestContext?: {
44
+ readonly domainName?: string;
45
+ readonly http?: {
46
+ readonly method?: string;
47
+ readonly sourceIp?: string;
48
+ };
49
+ };
50
+ }
51
+ /** The Lambda invocation context. The adapter only forwards it; declare fields structurally. */
52
+ export interface LambdaContext {
53
+ readonly awsRequestId?: string;
54
+ readonly functionName?: string;
55
+ readonly [key: string]: unknown;
56
+ }
57
+ /** What handlers see as `c.env` (declare your app as `server<LambdaEnv>()` for typed access). */
58
+ export interface LambdaEnv {
59
+ readonly event: LambdaEvent;
60
+ readonly context: LambdaContext | undefined;
61
+ }
62
+ /** A payload-v2 result object, as API Gateway and Function URLs consume it. */
63
+ export interface LambdaResult {
64
+ statusCode: number;
65
+ headers: Record<string, string>;
66
+ /** `Set-Cookie` values travel here (payload v2), one entry per cookie - never comma-joined. */
67
+ cookies?: string[];
68
+ body: string;
69
+ isBase64Encoded: boolean;
70
+ }
71
+ export interface LambdaOptions {
72
+ /**
73
+ * Reject request bodies whose *decoded* size exceeds this many bytes with a flat 413 before a
74
+ * `Request` is constructed. Defaults to 1,000,000 (nifra core's default). Set it to match your
75
+ * app's `maxBodyBytes` if you customized that.
76
+ */
77
+ readonly maxBodyBytes?: number;
78
+ }
79
+ /** The Node `Writable`-shaped stream Lambda hands a streaming handler. Declared structurally. */
80
+ export interface ResponseStream {
81
+ write(chunk: Uint8Array | string): unknown;
82
+ end(): unknown;
83
+ once?(event: string, listener: () => void): unknown;
84
+ }
85
+ /**
86
+ * A buffered Lambda handler for API Gateway HTTP APIs (payload v2) and Function URLs.
87
+ *
88
+ * ```ts
89
+ * import { handle } from "@nifrajs/aws-lambda"
90
+ * import { app } from "./app.js" // built once per container, at module scope
91
+ *
92
+ * export const handler = handle(app)
93
+ * ```
94
+ */
95
+ export declare function handle(app: FetchHandler, options?: LambdaOptions): (event: LambdaEvent, context?: LambdaContext) => Promise<LambdaResult>;
96
+ /**
97
+ * A streaming Lambda handler for Function URLs with `InvokeMode: RESPONSE_STREAM`, via the
98
+ * runtime's `awslambda.streamifyResponse`. Response bytes flow as the app produces them - no
99
+ * buffering, no base64. Requires the managed AWS Lambda Node runtime (the `awslambda` global);
100
+ * API Gateway does not support response streaming - use {@link handle} there.
101
+ */
102
+ export declare function streamHandle(app: FetchHandler, options?: LambdaOptions): (event: LambdaEvent, responseStream: ResponseStream, context?: LambdaContext) => Promise<void>;
103
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * AWS Lambda adapter for nifra - runs an app behind API Gateway HTTP APIs (payload format v2) and
3
+ * Lambda Function URLs (the same v2 shape), buffered via {@link handle} or streamed via
4
+ * {@link streamHandle}. REST APIs (payload v1) and ALB events are out of scope.
5
+ *
6
+ * Security posture, by construction:
7
+ * - Request headers are assembled from the event in exactly one place ({@link requestHeaders}):
8
+ * the `headers` map plus the separate `cookies` array. There is no second merge site, and the
9
+ * `cookies` array is canonical - a `cookie` key smuggled into `headers` never reaches the app.
10
+ * - The body is decoded first (base64 or UTF-8) and its real `byteLength` checked against
11
+ * `maxBodyBytes` *before* a `Request` is constructed. The event's own claims about length -
12
+ * `content-length` or the encoded size - are never trusted.
13
+ * - Response `isBase64Encoded` is decided by what the bytes actually are (strict UTF-8 decode),
14
+ * never by content-type guessing.
15
+ * - Uncaught errors become the same flat `internal_error` 500 as every other nifra runtime; the
16
+ * event is never echoed.
17
+ *
18
+ * The adapter is dependency-free: it accepts anything with a fetch-shaped `fetch` method and
19
+ * declares the Lambda event/`awslambda` shapes structurally.
20
+ */
21
+ const DEFAULT_MAX_BODY_BYTES = 1_000_000;
22
+ const assertBodyLimit = (value) => {
23
+ if (!Number.isSafeInteger(value) || value < 0)
24
+ throw new RangeError("maxBodyBytes must be a non-negative safe integer");
25
+ };
26
+ const TEXT_ENCODER = new TextEncoder();
27
+ // `fatal` makes invalid UTF-8 throw (the base64 signal); `ignoreBOM` keeps a leading BOM in the
28
+ // decoded string so a clean decode always re-encodes byte-identically - the honesty invariant.
29
+ const UTF8_STRICT = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
30
+ const nodeBuffer = globalThis.Buffer;
31
+ const fromBase64 = (data) => {
32
+ if (nodeBuffer !== undefined)
33
+ return nodeBuffer.from(data, "base64");
34
+ const binary = atob(data);
35
+ const bytes = new Uint8Array(binary.length);
36
+ for (let i = 0; i < binary.length; i++)
37
+ bytes[i] = binary.charCodeAt(i);
38
+ return bytes;
39
+ };
40
+ const toBase64 = (bytes) => {
41
+ if (nodeBuffer !== undefined)
42
+ return nodeBuffer.from(bytes).toString("base64");
43
+ let binary = "";
44
+ for (const byte of bytes)
45
+ binary += String.fromCharCode(byte);
46
+ return btoa(binary);
47
+ };
48
+ /**
49
+ * The single place request headers come from. Payload v2 pre-joins repeated headers with commas in
50
+ * `headers` and moves cookies into the `cookies` array; that array is set last, unconditionally,
51
+ * so it is canonical even against an event that (out of spec) also carries a `cookie` header.
52
+ */
53
+ const requestHeaders = (event) => {
54
+ const headers = new Headers();
55
+ if (event.headers !== undefined) {
56
+ for (const [name, value] of Object.entries(event.headers)) {
57
+ if (value !== undefined)
58
+ headers.set(name, value);
59
+ }
60
+ }
61
+ if (event.cookies !== undefined && event.cookies.length > 0) {
62
+ headers.set("cookie", event.cookies.join("; "));
63
+ }
64
+ else {
65
+ headers.delete("cookie");
66
+ }
67
+ return headers;
68
+ };
69
+ const flatError = (status, code) => new Response(`{"ok":false,"error":"${code}"}`, {
70
+ status,
71
+ headers: { "content-type": "application/json" },
72
+ });
73
+ /**
74
+ * Build the `Request`, or a flat error `Response` when the event must be rejected before the app
75
+ * runs. The body is decoded and measured first - a 413 here means no `Request` ever existed.
76
+ */
77
+ const toRequest = (event, maxBodyBytes) => {
78
+ const method = event.requestContext?.http?.method ?? "GET";
79
+ const headers = requestHeaders(event);
80
+ const authority = event.requestContext?.domainName ?? headers.get("host") ?? "localhost";
81
+ const path = event.rawPath !== undefined && event.rawPath !== "" ? event.rawPath : "/";
82
+ const query = event.rawQueryString !== undefined && event.rawQueryString !== ""
83
+ ? `?${event.rawQueryString}`
84
+ : "";
85
+ const url = `https://${authority}${path}${query}`;
86
+ let body;
87
+ if (event.body !== undefined && event.body !== null && method !== "GET" && method !== "HEAD") {
88
+ body = event.isBase64Encoded === true ? fromBase64(event.body) : TEXT_ENCODER.encode(event.body);
89
+ if (body.byteLength > maxBodyBytes)
90
+ return flatError(413, "payload_too_large");
91
+ }
92
+ return body === undefined
93
+ ? new Request(url, { method, headers })
94
+ : new Request(url, { method, headers, body });
95
+ };
96
+ /**
97
+ * Response headers as a plain object for the v2 result, with `set-cookie` split out into the
98
+ * `cookies` array (comma-joining cookies corrupts them) and `content-length` dropped - the
99
+ * transport owns framing, and a stale length from before base64 re-encoding must not survive.
100
+ */
101
+ const resultHeaders = (response) => {
102
+ const headers = {};
103
+ for (const [name, value] of response.headers) {
104
+ if (name === "set-cookie" || name === "content-length")
105
+ continue;
106
+ headers[name] = value;
107
+ }
108
+ return { headers, cookies: response.headers.getSetCookie() };
109
+ };
110
+ const toResult = async (response) => {
111
+ const { headers, cookies } = resultHeaders(response);
112
+ const bytes = new Uint8Array(await response.arrayBuffer());
113
+ let body;
114
+ let isBase64Encoded;
115
+ try {
116
+ body = UTF8_STRICT.decode(bytes);
117
+ isBase64Encoded = false;
118
+ }
119
+ catch {
120
+ body = toBase64(bytes);
121
+ isBase64Encoded = true;
122
+ }
123
+ const result = { statusCode: response.status, headers, body, isBase64Encoded };
124
+ if (cookies.length > 0)
125
+ result.cookies = cookies;
126
+ return result;
127
+ };
128
+ const INTERNAL_ERROR = () => flatError(500, "internal_error");
129
+ /** Run the app for one event. Never throws: failures collapse to the flat 500. */
130
+ const invoke = async (app, event, context, maxBodyBytes) => {
131
+ const pending = [];
132
+ try {
133
+ const built = toRequest(event, maxBodyBytes);
134
+ if (built instanceof Response)
135
+ return { response: built, pending };
136
+ const response = await app.fetch(built, {
137
+ env: { event, context },
138
+ clientIp: event.requestContext?.http?.sourceIp,
139
+ waitUntil: (promise) => {
140
+ pending.push(promise);
141
+ },
142
+ });
143
+ return { response, pending };
144
+ }
145
+ catch {
146
+ return { response: INTERNAL_ERROR(), pending };
147
+ }
148
+ };
149
+ // Background work must settle before the handler returns - Lambda freezes the container the
150
+ // moment it does, so a fire-and-forget promise would silently stall until (maybe) the next
151
+ // invocation thaws it. `allSettled` so a failed background task cannot corrupt the response.
152
+ const settle = (pending) => pending.length > 0 ? Promise.allSettled(pending) : undefined;
153
+ /**
154
+ * A buffered Lambda handler for API Gateway HTTP APIs (payload v2) and Function URLs.
155
+ *
156
+ * ```ts
157
+ * import { handle } from "@nifrajs/aws-lambda"
158
+ * import { app } from "./app.ts" // built once per container, at module scope
159
+ *
160
+ * export const handler = handle(app)
161
+ * ```
162
+ */
163
+ export function handle(app, options = {}) {
164
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
165
+ assertBodyLimit(maxBodyBytes);
166
+ return async (event, context) => {
167
+ const { response, pending } = await invoke(app, event, context, maxBodyBytes);
168
+ try {
169
+ const result = await toResult(response);
170
+ await settle(pending);
171
+ return result;
172
+ }
173
+ catch {
174
+ await settle(pending);
175
+ return {
176
+ statusCode: 500,
177
+ headers: { "content-type": "application/json" },
178
+ body: '{"ok":false,"error":"internal_error"}',
179
+ isBase64Encoded: false,
180
+ };
181
+ }
182
+ };
183
+ }
184
+ /**
185
+ * A streaming Lambda handler for Function URLs with `InvokeMode: RESPONSE_STREAM`, via the
186
+ * runtime's `awslambda.streamifyResponse`. Response bytes flow as the app produces them - no
187
+ * buffering, no base64. Requires the managed AWS Lambda Node runtime (the `awslambda` global);
188
+ * API Gateway does not support response streaming - use {@link handle} there.
189
+ */
190
+ export function streamHandle(app, options = {}) {
191
+ const awslambda = globalThis.awslambda;
192
+ if (awslambda === undefined) {
193
+ throw new Error("[nifra/aws-lambda] streamHandle requires the AWS Lambda Node runtime (no `awslambda` global found) - use handle() elsewhere");
194
+ }
195
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
196
+ assertBodyLimit(maxBodyBytes);
197
+ return awslambda.streamifyResponse(async (event, responseStream, context) => {
198
+ const { response, pending } = await invoke(app, event, context, maxBodyBytes);
199
+ const { headers, cookies } = resultHeaders(response);
200
+ const metadata = { statusCode: response.status, headers };
201
+ if (cookies.length > 0)
202
+ metadata.cookies = cookies;
203
+ const out = awslambda.HttpResponseStream.from(responseStream, metadata);
204
+ try {
205
+ if (response.body !== null) {
206
+ for await (const chunk of response.body) {
207
+ if (out.write(chunk) === false && typeof out.once === "function") {
208
+ await new Promise((resolve) => {
209
+ ;
210
+ out.once("drain", resolve);
211
+ });
212
+ }
213
+ }
214
+ }
215
+ }
216
+ finally {
217
+ // Headers are already on the wire once `from` returns; on a mid-stream failure the only
218
+ // honest move left is to end the stream - never to emit a second response.
219
+ out.end();
220
+ await settle(pending);
221
+ }
222
+ });
223
+ }
224
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@nifrajs/aws-lambda",
3
+ "version": "2.12.0",
4
+ "description": "AWS Lambda adapter for nifra - API Gateway HTTP API (payload v2) and Function URLs, buffered or streaming, with post-decode body limits and single-site header merging. Dependency-free.",
5
+ "keywords": [
6
+ "bun",
7
+ "typescript",
8
+ "nifra",
9
+ "aws",
10
+ "aws-lambda",
11
+ "api-gateway",
12
+ "serverless",
13
+ "adapter",
14
+ "edge"
15
+ ],
16
+ "type": "module",
17
+ "main": "./dist/index.js",
18
+ "module": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "bun": "./src/index.ts",
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "src"
30
+ ],
31
+ "sideEffects": false,
32
+ "scripts": {
33
+ "build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
34
+ },
35
+ "devDependencies": {
36
+ "@nifrajs/core": "workspace:*"
37
+ },
38
+ "license": "MIT",
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/nifrajs/nifra.git",
45
+ "directory": "packages/aws-lambda"
46
+ },
47
+ "bugs": {
48
+ "url": "https://github.com/nifrajs/nifra/issues"
49
+ },
50
+ "homepage": "https://github.com/nifrajs/nifra#readme"
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,344 @@
1
+ /**
2
+ * AWS Lambda adapter for nifra - runs an app behind API Gateway HTTP APIs (payload format v2) and
3
+ * Lambda Function URLs (the same v2 shape), buffered via {@link handle} or streamed via
4
+ * {@link streamHandle}. REST APIs (payload v1) and ALB events are out of scope.
5
+ *
6
+ * Security posture, by construction:
7
+ * - Request headers are assembled from the event in exactly one place ({@link requestHeaders}):
8
+ * the `headers` map plus the separate `cookies` array. There is no second merge site, and the
9
+ * `cookies` array is canonical - a `cookie` key smuggled into `headers` never reaches the app.
10
+ * - The body is decoded first (base64 or UTF-8) and its real `byteLength` checked against
11
+ * `maxBodyBytes` *before* a `Request` is constructed. The event's own claims about length -
12
+ * `content-length` or the encoded size - are never trusted.
13
+ * - Response `isBase64Encoded` is decided by what the bytes actually are (strict UTF-8 decode),
14
+ * never by content-type guessing.
15
+ * - Uncaught errors become the same flat `internal_error` 500 as every other nifra runtime; the
16
+ * event is never echoed.
17
+ *
18
+ * The adapter is dependency-free: it accepts anything with a fetch-shaped `fetch` method and
19
+ * declares the Lambda event/`awslambda` shapes structurally.
20
+ */
21
+
22
+ /** Platform fields the adapter feeds into `app.fetch` (mirrors nifra core's `Platform`). */
23
+ export interface LambdaPlatform {
24
+ readonly env: LambdaEnv
25
+ readonly clientIp?: string | undefined
26
+ readonly waitUntil?: (promise: Promise<unknown>) => void
27
+ }
28
+
29
+ /** Anything with a fetch-shaped entry point - a nifra `server()` app satisfies this. */
30
+ export interface FetchHandler {
31
+ fetch(request: Request, platform?: LambdaPlatform): Response | Promise<Response>
32
+ }
33
+
34
+ /**
35
+ * The slice of an API Gateway HTTP API / Function URL payload-v2 event the adapter reads.
36
+ * Extra fields pass through untouched on `c.env.event`.
37
+ */
38
+ export interface LambdaEvent {
39
+ readonly rawPath?: string
40
+ readonly rawQueryString?: string
41
+ readonly headers?: Readonly<Record<string, string | undefined>>
42
+ /** Payload v2 delivers cookies here, not in `headers` - this array is the canonical source. */
43
+ readonly cookies?: readonly string[]
44
+ readonly body?: string | null
45
+ readonly isBase64Encoded?: boolean
46
+ readonly requestContext?: {
47
+ readonly domainName?: string
48
+ readonly http?: {
49
+ readonly method?: string
50
+ readonly sourceIp?: string
51
+ }
52
+ }
53
+ }
54
+
55
+ /** The Lambda invocation context. The adapter only forwards it; declare fields structurally. */
56
+ export interface LambdaContext {
57
+ readonly awsRequestId?: string
58
+ readonly functionName?: string
59
+ readonly [key: string]: unknown
60
+ }
61
+
62
+ /** What handlers see as `c.env` (declare your app as `server<LambdaEnv>()` for typed access). */
63
+ export interface LambdaEnv {
64
+ readonly event: LambdaEvent
65
+ readonly context: LambdaContext | undefined
66
+ }
67
+
68
+ /** A payload-v2 result object, as API Gateway and Function URLs consume it. */
69
+ export interface LambdaResult {
70
+ statusCode: number
71
+ headers: Record<string, string>
72
+ /** `Set-Cookie` values travel here (payload v2), one entry per cookie - never comma-joined. */
73
+ cookies?: string[]
74
+ body: string
75
+ isBase64Encoded: boolean
76
+ }
77
+
78
+ export interface LambdaOptions {
79
+ /**
80
+ * Reject request bodies whose *decoded* size exceeds this many bytes with a flat 413 before a
81
+ * `Request` is constructed. Defaults to 1,000,000 (nifra core's default). Set it to match your
82
+ * app's `maxBodyBytes` if you customized that.
83
+ */
84
+ readonly maxBodyBytes?: number
85
+ }
86
+
87
+ /** The Node `Writable`-shaped stream Lambda hands a streaming handler. Declared structurally. */
88
+ export interface ResponseStream {
89
+ write(chunk: Uint8Array | string): unknown
90
+ end(): unknown
91
+ once?(event: string, listener: () => void): unknown
92
+ }
93
+
94
+ interface StreamMetadata {
95
+ statusCode: number
96
+ headers: Record<string, string>
97
+ cookies?: string[]
98
+ }
99
+
100
+ /** The `awslambda` global the managed Node runtime injects for response streaming. */
101
+ interface AwsLambdaGlobal {
102
+ streamifyResponse(
103
+ handler: (
104
+ event: LambdaEvent,
105
+ responseStream: ResponseStream,
106
+ context?: LambdaContext,
107
+ ) => Promise<void>,
108
+ ): (event: LambdaEvent, responseStream: ResponseStream, context?: LambdaContext) => Promise<void>
109
+ HttpResponseStream: {
110
+ from(stream: ResponseStream, metadata: StreamMetadata): ResponseStream
111
+ }
112
+ }
113
+
114
+ const DEFAULT_MAX_BODY_BYTES = 1_000_000
115
+ const assertBodyLimit = (value: number): void => {
116
+ if (!Number.isSafeInteger(value) || value < 0)
117
+ throw new RangeError("maxBodyBytes must be a non-negative safe integer")
118
+ }
119
+
120
+ const TEXT_ENCODER = new TextEncoder()
121
+ // `fatal` makes invalid UTF-8 throw (the base64 signal); `ignoreBOM` keeps a leading BOM in the
122
+ // decoded string so a clean decode always re-encodes byte-identically - the honesty invariant.
123
+ const UTF8_STRICT = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true })
124
+
125
+ interface BufferLike {
126
+ from(data: string, encoding: string): Uint8Array
127
+ from(data: Uint8Array): { toString(encoding: string): string }
128
+ }
129
+
130
+ const nodeBuffer = (globalThis as { Buffer?: BufferLike }).Buffer
131
+
132
+ const fromBase64 = (data: string): Uint8Array => {
133
+ if (nodeBuffer !== undefined) return nodeBuffer.from(data, "base64")
134
+ const binary = atob(data)
135
+ const bytes = new Uint8Array(binary.length)
136
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
137
+ return bytes
138
+ }
139
+
140
+ const toBase64 = (bytes: Uint8Array): string => {
141
+ if (nodeBuffer !== undefined) return nodeBuffer.from(bytes).toString("base64")
142
+ let binary = ""
143
+ for (const byte of bytes) binary += String.fromCharCode(byte)
144
+ return btoa(binary)
145
+ }
146
+
147
+ /**
148
+ * The single place request headers come from. Payload v2 pre-joins repeated headers with commas in
149
+ * `headers` and moves cookies into the `cookies` array; that array is set last, unconditionally,
150
+ * so it is canonical even against an event that (out of spec) also carries a `cookie` header.
151
+ */
152
+ const requestHeaders = (event: LambdaEvent): Headers => {
153
+ const headers = new Headers()
154
+ if (event.headers !== undefined) {
155
+ for (const [name, value] of Object.entries(event.headers)) {
156
+ if (value !== undefined) headers.set(name, value)
157
+ }
158
+ }
159
+ if (event.cookies !== undefined && event.cookies.length > 0) {
160
+ headers.set("cookie", event.cookies.join("; "))
161
+ } else {
162
+ headers.delete("cookie")
163
+ }
164
+ return headers
165
+ }
166
+
167
+ const flatError = (status: number, code: string): Response =>
168
+ new Response(`{"ok":false,"error":"${code}"}`, {
169
+ status,
170
+ headers: { "content-type": "application/json" },
171
+ })
172
+
173
+ /**
174
+ * Build the `Request`, or a flat error `Response` when the event must be rejected before the app
175
+ * runs. The body is decoded and measured first - a 413 here means no `Request` ever existed.
176
+ */
177
+ const toRequest = (event: LambdaEvent, maxBodyBytes: number): Request | Response => {
178
+ const method = event.requestContext?.http?.method ?? "GET"
179
+ const headers = requestHeaders(event)
180
+ const authority = event.requestContext?.domainName ?? headers.get("host") ?? "localhost"
181
+ const path = event.rawPath !== undefined && event.rawPath !== "" ? event.rawPath : "/"
182
+ const query =
183
+ event.rawQueryString !== undefined && event.rawQueryString !== ""
184
+ ? `?${event.rawQueryString}`
185
+ : ""
186
+ const url = `https://${authority}${path}${query}`
187
+
188
+ let body: Uint8Array | undefined
189
+ if (event.body !== undefined && event.body !== null && method !== "GET" && method !== "HEAD") {
190
+ body = event.isBase64Encoded === true ? fromBase64(event.body) : TEXT_ENCODER.encode(event.body)
191
+ if (body.byteLength > maxBodyBytes) return flatError(413, "payload_too_large")
192
+ }
193
+
194
+ return body === undefined
195
+ ? new Request(url, { method, headers })
196
+ : new Request(url, { method, headers, body })
197
+ }
198
+
199
+ /**
200
+ * Response headers as a plain object for the v2 result, with `set-cookie` split out into the
201
+ * `cookies` array (comma-joining cookies corrupts them) and `content-length` dropped - the
202
+ * transport owns framing, and a stale length from before base64 re-encoding must not survive.
203
+ */
204
+ const resultHeaders = (
205
+ response: Response,
206
+ ): { headers: Record<string, string>; cookies: string[] } => {
207
+ const headers: Record<string, string> = {}
208
+ for (const [name, value] of response.headers) {
209
+ if (name === "set-cookie" || name === "content-length") continue
210
+ headers[name] = value
211
+ }
212
+ return { headers, cookies: response.headers.getSetCookie() }
213
+ }
214
+
215
+ const toResult = async (response: Response): Promise<LambdaResult> => {
216
+ const { headers, cookies } = resultHeaders(response)
217
+ const bytes = new Uint8Array(await response.arrayBuffer())
218
+ let body: string
219
+ let isBase64Encoded: boolean
220
+ try {
221
+ body = UTF8_STRICT.decode(bytes)
222
+ isBase64Encoded = false
223
+ } catch {
224
+ body = toBase64(bytes)
225
+ isBase64Encoded = true
226
+ }
227
+ const result: LambdaResult = { statusCode: response.status, headers, body, isBase64Encoded }
228
+ if (cookies.length > 0) result.cookies = cookies
229
+ return result
230
+ }
231
+
232
+ const INTERNAL_ERROR = (): Response => flatError(500, "internal_error")
233
+
234
+ interface Invocation {
235
+ response: Response
236
+ pending: readonly Promise<unknown>[]
237
+ }
238
+
239
+ /** Run the app for one event. Never throws: failures collapse to the flat 500. */
240
+ const invoke = async (
241
+ app: FetchHandler,
242
+ event: LambdaEvent,
243
+ context: LambdaContext | undefined,
244
+ maxBodyBytes: number,
245
+ ): Promise<Invocation> => {
246
+ const pending: Promise<unknown>[] = []
247
+ try {
248
+ const built = toRequest(event, maxBodyBytes)
249
+ if (built instanceof Response) return { response: built, pending }
250
+ const response = await app.fetch(built, {
251
+ env: { event, context },
252
+ clientIp: event.requestContext?.http?.sourceIp,
253
+ waitUntil: (promise) => {
254
+ pending.push(promise)
255
+ },
256
+ })
257
+ return { response, pending }
258
+ } catch {
259
+ return { response: INTERNAL_ERROR(), pending }
260
+ }
261
+ }
262
+
263
+ // Background work must settle before the handler returns - Lambda freezes the container the
264
+ // moment it does, so a fire-and-forget promise would silently stall until (maybe) the next
265
+ // invocation thaws it. `allSettled` so a failed background task cannot corrupt the response.
266
+ const settle = (pending: readonly Promise<unknown>[]): Promise<unknown> | undefined =>
267
+ pending.length > 0 ? Promise.allSettled(pending) : undefined
268
+
269
+ /**
270
+ * A buffered Lambda handler for API Gateway HTTP APIs (payload v2) and Function URLs.
271
+ *
272
+ * ```ts
273
+ * import { handle } from "@nifrajs/aws-lambda"
274
+ * import { app } from "./app.ts" // built once per container, at module scope
275
+ *
276
+ * export const handler = handle(app)
277
+ * ```
278
+ */
279
+ export function handle(
280
+ app: FetchHandler,
281
+ options: LambdaOptions = {},
282
+ ): (event: LambdaEvent, context?: LambdaContext) => Promise<LambdaResult> {
283
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
284
+ assertBodyLimit(maxBodyBytes)
285
+ return async (event, context) => {
286
+ const { response, pending } = await invoke(app, event, context, maxBodyBytes)
287
+ try {
288
+ const result = await toResult(response)
289
+ await settle(pending)
290
+ return result
291
+ } catch {
292
+ await settle(pending)
293
+ return {
294
+ statusCode: 500,
295
+ headers: { "content-type": "application/json" },
296
+ body: '{"ok":false,"error":"internal_error"}',
297
+ isBase64Encoded: false,
298
+ }
299
+ }
300
+ }
301
+ }
302
+
303
+ /**
304
+ * A streaming Lambda handler for Function URLs with `InvokeMode: RESPONSE_STREAM`, via the
305
+ * runtime's `awslambda.streamifyResponse`. Response bytes flow as the app produces them - no
306
+ * buffering, no base64. Requires the managed AWS Lambda Node runtime (the `awslambda` global);
307
+ * API Gateway does not support response streaming - use {@link handle} there.
308
+ */
309
+ export function streamHandle(
310
+ app: FetchHandler,
311
+ options: LambdaOptions = {},
312
+ ): (event: LambdaEvent, responseStream: ResponseStream, context?: LambdaContext) => Promise<void> {
313
+ const awslambda = (globalThis as { awslambda?: AwsLambdaGlobal }).awslambda
314
+ if (awslambda === undefined) {
315
+ throw new Error(
316
+ "[nifra/aws-lambda] streamHandle requires the AWS Lambda Node runtime (no `awslambda` global found) - use handle() elsewhere",
317
+ )
318
+ }
319
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
320
+ assertBodyLimit(maxBodyBytes)
321
+ return awslambda.streamifyResponse(async (event, responseStream, context) => {
322
+ const { response, pending } = await invoke(app, event, context, maxBodyBytes)
323
+ const { headers, cookies } = resultHeaders(response)
324
+ const metadata: StreamMetadata = { statusCode: response.status, headers }
325
+ if (cookies.length > 0) metadata.cookies = cookies
326
+ const out = awslambda.HttpResponseStream.from(responseStream, metadata)
327
+ try {
328
+ if (response.body !== null) {
329
+ for await (const chunk of response.body) {
330
+ if (out.write(chunk) === false && typeof out.once === "function") {
331
+ await new Promise<void>((resolve) => {
332
+ ;(out.once as (event: string, listener: () => void) => unknown)("drain", resolve)
333
+ })
334
+ }
335
+ }
336
+ }
337
+ } finally {
338
+ // Headers are already on the wire once `from` returns; on a mid-stream failure the only
339
+ // honest move left is to end the stream - never to emit a second response.
340
+ out.end()
341
+ await settle(pending)
342
+ }
343
+ })
344
+ }