1/**
2 * `@alchemy.run/frontend-frameworks/aws-lambda` — the web adapter AWS
3 * deploy targets wrap around a framework's fetch handler.
4 *
5 * A Lambda behind a Function URL receives payload-v2 events. Frameworks
6 * (Astro, SvelteKit, Waku, Octane, ...) emit a web-standard fetch handler
7 * (`(request: Request) => Promise<Response>`). This module converts
8 * between the two:
9 *
10 * - {@link toRequest} — payload-v2 event → web `Request`, honoring
11 * `x-forwarded-host`/`x-forwarded-proto` (set by the CloudFront edge
12 * router) so the framework sees the viewer-facing URL.
13 * - {@link toLambdaHandler} — fetch handler → streaming Lambda handler
14 * (`awslambda.streamifyResponse` + `HttpResponseStream`); requires the
15 * Function URL to use `invokeMode: RESPONSE_STREAM`.
16 * - {@link toBufferedLambdaHandler} — fetch handler → buffered handler
17 * returning a complete payload-v2 response object, for `invokeMode:
18 * BUFFERED` (the default) and API Gateway HTTP APIs.
19 */
20
21/** The slice of a Lambda Function URL (payload v2) event this adapter reads. */
22export interface FunctionUrlEvent {
23 readonly version?: string;
24 readonly rawPath: string;
25 readonly rawQueryString?: string;
26 readonly headers?: Record<string, string | undefined>;
27 readonly cookies?: ReadonlyArray<string>;
28 readonly body?: string | undefined;
29 readonly isBase64Encoded?: boolean;
30 readonly requestContext: {
31 readonly domainName?: string;
32 readonly http: {
33 readonly method: string;
34 readonly sourceIp?: string;
35 };
36 };
37}
38
39/** The payload-v2 response shape the buffered handler returns. */
40export interface FunctionUrlResult {
41 statusCode: number;
42 headers: Record<string, string>;
43 cookies?: Array<string>;
44 body: string;
45 isBase64Encoded: boolean;
46}
47
48/** A web-standard fetch handler as frameworks emit them. */
49export type FetchHandler = (request: Request) => Response | Promise<Response>;
50
51/**
52 * The `awslambda` global the Lambda Node.js runtime provides. Declared
53 * structurally so this module type-checks outside the Lambda sandbox.
54 */
55interface AwsLambdaGlobal {
56 streamifyResponse: (
57 handler: (
58 event: FunctionUrlEvent,
59 responseStream: NodeJS.WritableStream & { end: () => void },
60 context: unknown,
61 ) => Promise<void>,
62 ) => unknown;
63 HttpResponseStream: {
64 from: (
65 stream: unknown,
66 metadata: {
67 statusCode: number;
68 headers: Record<string, string>;
69 cookies?: Array<string>;
70 },
71 ) => NodeJS.WritableStream & { end: () => void };
72 };
73}
74
75const awslambdaGlobal = (): AwsLambdaGlobal => {
76 const g = (globalThis as { awslambda?: AwsLambdaGlobal }).awslambda;
77 if (g === undefined) {
78 throw new Error(
79 "The `awslambda` global is missing — streaming handlers only run on the AWS Lambda Node.js runtime.",
80 );
81 }
82 return g;
83};
84
85/**
86 * Convert a Function URL (payload v2) event into a web `Request`.
87 *
88 * The URL host prefers `x-forwarded-host` (set by the CloudFront edge
89 * router) over the Function URL's own domain so framework-side absolute
90 * URLs (redirects, canonical links) carry the viewer-facing host.
91 */
92export const toRequest = (event: FunctionUrlEvent): Request => {
93 const headers = new Headers();
94 for (const [name, value] of Object.entries(event.headers ?? {})) {
95 if (value !== undefined) headers.set(name, value);
96 }
97 if (event.cookies !== undefined && event.cookies.length > 0) {
98 headers.set("cookie", event.cookies.join("; "));
99 }
100 const host =
101 headers.get("x-forwarded-host") ??
102 event.requestContext.domainName ??
103 headers.get("host") ??
104 "localhost";
105 const proto = headers.get("x-forwarded-proto") ?? "https";
106 const query = event.rawQueryString ? `?${event.rawQueryString}` : "";
107 const url = `${proto}://${host}${event.rawPath}${query}`;
108 const method = event.requestContext.http.method;
109 const body =
110 event.body === undefined || method === "GET" || method === "HEAD"
111 ? undefined
112 : event.isBase64Encoded
113 ? Buffer.from(event.body, "base64")
114 : event.body;
115 return new Request(url, {
116 method,
117 headers,
118 body,
119 // Node's fetch types require half-duplex for streamed bodies.
120 ...(body !== undefined ? { duplex: "half" } : {}),
121 } as RequestInit);
122};
123
124/** Split a `Response`'s headers into plain headers + set-cookie list. */
125const splitHeaders = (
126 response: Response,
127): { headers: Record<string, string>; cookies: Array<string> } => {
128 const headers: Record<string, string> = {};
129 response.headers.forEach((value, name) => {
130 if (name.toLowerCase() !== "set-cookie") headers[name] = value;
131 });
132 const cookies = response.headers.getSetCookie?.() ?? [];
133 return { headers, cookies };
134};
135
136/**
137 * Wrap a fetch handler as a **streaming** Lambda handler. Export the result
138 * as `handler` and deploy behind a Function URL with
139 * `invokeMode: RESPONSE_STREAM`.
140 *
141 * ```ts
142 * import { toLambdaHandler } from "@alchemy.run/frontend-frameworks/aws-lambda";
143 * import { fetchHandler } from "./framework-server-entry";
144 *
145 * export const handler = toLambdaHandler(fetchHandler);
146 * ```
147 */
148export const toLambdaHandler = (fetchHandler: FetchHandler): unknown => {
149 const aws = awslambdaGlobal();
150 return aws.streamifyResponse(async (event, responseStream) => {
151 const response = await fetchHandler(toRequest(event));
152 const { headers, cookies } = splitHeaders(response);
153 const stream = aws.HttpResponseStream.from(responseStream, {
154 statusCode: response.status,
155 headers,
156 ...(cookies.length > 0 ? { cookies } : {}),
157 });
158 if (response.body === null) {
159 // HttpResponseStream requires at least one write for the metadata
160 // frame to flush.
161 stream.write("");
162 stream.end();
163 return;
164 }
165 const reader = response.body.getReader();
166 for (;;) {
167 const { done, value } = await reader.read();
168 if (done) break;
169 stream.write(value);
170 }
171 stream.end();
172 });
173};
174
175/**
176 * Wrap a fetch handler as a **buffered** Lambda handler returning a
177 * complete payload-v2 response — for `invokeMode: BUFFERED` Function URLs
178 * and API Gateway HTTP APIs.
179 */
180export const toBufferedLambdaHandler =
181 (fetchHandler: FetchHandler) =>
182 async (event: FunctionUrlEvent): Promise<FunctionUrlResult> => {
183 const response = await fetchHandler(toRequest(event));
184 const { headers, cookies } = splitHeaders(response);
185 const bytes = Buffer.from(await response.arrayBuffer());
186 const contentType = response.headers.get("content-type") ?? "";
187 const isText =
188 /^text\/|application\/(json|javascript|xml|xhtml\+xml|rss\+xml)/.test(
189 contentType,
190 );
191 return {
192 statusCode: response.status,
193 headers,
194 ...(cookies.length > 0 ? { cookies } : {}),
195 body: isText ? bytes.toString("utf8") : bytes.toString("base64"),
196 isBase64Encoded: !isText,
197 };
198 };