| 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. */ |
| 22 | export 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. */ |
| 40 | export 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. */ |
| 49 | export 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 | */ |
| 55 | interface 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 | |
| 75 | const 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 | */ |
| 92 | export 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. */ |
| 125 | const 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 | */ |
| 148 | export 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 | */ |
| 180 | export 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 | }; |