UNPKG

astro-sst

Version:

Adapter that allows Astro to deploy your site to AWS utilizing SST.

487 lines (431 loc) 12.4 kB
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2, APIGatewayProxyEvent, APIGatewayProxyResult, CloudFrontRequestEvent, CloudFrontRequestResult, CloudFrontHeaders, } from "aws-lambda"; import type { ResponseStream } from "./types"; import { splitCookiesString, parse, Cookie } from "set-cookie-parser"; import { isBinaryContentType } from "./binary.js"; import zlib from "zlib"; export type InternalEvent = { readonly type: "v1" | "v2" | "cf"; readonly method: string; readonly queryString: string; readonly rawPath: string; readonly url: string; readonly body: Buffer; readonly headers: Record<string, string>; readonly remoteAddress: string; }; type InternalResultInput = { readonly type: "v1" | "v2" | "cf"; response: Response; responseStream?: ResponseStream; cookies?: string[]; }; type InternalResult = { readonly type: "v1" | "v2" | "cf"; statusCode: number; headers: Record<string, string>; cookies: Cookie[]; body: string; isBase64Encoded: boolean; }; type InternalStreamingResult = { statusCode: number; headers: Record<string, string>; cookies: Cookie[]; body: ReadableStream | null; responseStream: ResponseStream; isBase64Encoded: boolean; }; function isApigV2Event(event: any): event is APIGatewayProxyEventV2 { return event.version === "2.0"; } function isApigV1Event(event: any): event is APIGatewayProxyEvent { return event.version === undefined && !isCfEvent(event); } function isCfEvent(event: any): event is CloudFrontRequestEvent { return event.Records !== undefined; } export function convertFrom( event: APIGatewayProxyEventV2 | APIGatewayProxyEvent | CloudFrontRequestEvent ) { let iEvent: Omit<InternalEvent, "url">; if (isCfEvent(event)) { iEvent = convertFromCfEvent(event); } else if (isApigV2Event(event)) { iEvent = convertFromApigV2Event(event); } else if (isApigV1Event(event)) { iEvent = convertFromApigV1Event(event); } else { throw new Error("Unsupported event type"); } // Fix host header if (iEvent.headers["x-forwarded-host"]) { iEvent.headers.host = iEvent.headers["x-forwarded-host"]; } // Build URL const scheme = iEvent.headers["x-forwarded-protocol"] || "https"; const url = new URL( iEvent.queryString ? `${iEvent.rawPath}?${iEvent.queryString}` : iEvent.rawPath, `${scheme}://${iEvent.headers.host}` ).toString(); return { ...iEvent, url } satisfies InternalEvent; } function convertFromApigV1Event(event: APIGatewayProxyEvent) { const { path, body, httpMethod, requestContext, isBase64Encoded } = event; const headers = normalizeApigV1Headers(event); return { type: "v1" as const, method: httpMethod, rawPath: path, queryString: normalizeApigV1QueryParams(event), body: Buffer.from(body ?? "", isBase64Encoded ? "base64" : "utf8"), headers, remoteAddress: requestContext.identity.sourceIp, }; } function convertFromApigV2Event(event: APIGatewayProxyEventV2) { const { rawPath, rawQueryString, requestContext } = event; return { type: "v2" as const, method: requestContext.http.method, rawPath, queryString: rawQueryString, body: normalizeApigV2Body(event), headers: normalizeApigV2Headers(event), remoteAddress: requestContext.http.sourceIp, }; } function convertFromCfEvent(event: CloudFrontRequestEvent) { const { method, uri, querystring, body, clientIp } = event.Records[0].cf.request; return { type: "cf" as const, method, rawPath: uri, queryString: querystring, body: Buffer.from( body?.data ?? "", body?.encoding === "base64" ? "base64" : "utf8" ), headers: normalizeCfHeaders(event), remoteAddress: clientIp, }; } export async function convertTo({ type, response, responseStream, cookies: appCookies, }: InternalResultInput) { // Parse headers (except cookies) const headers: { [key: string]: string } = Array.from( response.headers.entries() ) .filter(([key]) => key !== "set-cookie") .reduce((headers, [key, value]) => { headers[key] = value; return headers; }, {} as { [key: string]: string }); // Parse cookies const cookies = parse( [ ...splitCookiesString(response.headers.getSetCookie() ?? undefined), ...(appCookies ?? []), ], { decodeValues: false, map: false, silent: true } ); // Parse isBase64Encoded const isBase64Encoded = isBinaryContentType(headers["content-type"]); // Build streaming result if (type === "v2" && responseStream) { return convertToApigV2StreamingResult({ statusCode: response.status, headers, body: response.body, cookies, responseStream, isBase64Encoded, }); } // Build non-streaming result const result = { type, statusCode: response.status, headers, cookies, isBase64Encoded, body: isBase64Encoded ? Buffer.from(await response.arrayBuffer()).toString("base64") : await response.text(), }; if (type === "v2") { return convertToApigV2Result(result); } else if (type === "v1") { return convertToApigV1Result(result); } else if (type === "cf") { return convertToCfResult(result); } throw new Error("Unsupported event type"); } function convertToApigV1Result({ headers, statusCode, body, isBase64Encoded, cookies, }: InternalResult): APIGatewayProxyResult { const multiValueHeaders: Record<string, string[]> = {}; if (cookies.length > 0) { multiValueHeaders["set-cookie"] = stringifyCookies(cookies); } const response: APIGatewayProxyResult = { statusCode, headers, multiValueHeaders, body, isBase64Encoded, }; return response; } function convertToApigV2Result({ headers, statusCode, body, isBase64Encoded, cookies, }: InternalResult): APIGatewayProxyResultV2 { const response: APIGatewayProxyResultV2 = { statusCode, headers, cookies: cookies.length > 0 ? stringifyCookies(cookies) : undefined, body, isBase64Encoded, }; return response; } function convertToApigV2StreamingResult({ statusCode, headers, cookies, body, responseStream, isBase64Encoded, }: InternalStreamingResult) { if (!isBase64Encoded) { headers["content-encoding"] = "gzip"; } const metadata = { statusCode, headers, }; if (cookies.length > 0) { metadata.headers["set-cookie"] = stringifyCookies(cookies).join(", "); } responseStream = awslambda.HttpResponseStream.from(responseStream, metadata); if (!body) { responseStream.write(0); responseStream.end(); return; } if (body.locked) { responseStream.write( "Fatal error: Response body is locked. " + `This can happen when the response was already read (for example through 'response.json()' or 'response.text()').` ); responseStream.end(); return; } const reader = body.getReader(); if (responseStream.destroyed) { reader.cancel(); return; } let streamToWrite: ResponseStream | zlib.Gzip; if (!isBase64Encoded) { const gzip = zlib.createGzip(); gzip.pipe(responseStream); streamToWrite = gzip; } else { streamToWrite = responseStream; } const cancel = (error?: Error) => { streamToWrite.off("close", cancel); streamToWrite.off("error", cancel); // If the reader has already been interrupted with an error earlier, // then it will appear here, it is useless, but it needs to be catch. reader.cancel(error).catch(() => {}); if (!isBase64Encoded) { // Unpipe the gzip stream to ensure no more data is written (streamToWrite as zlib.Gzip).unpipe(responseStream); if (error) { streamToWrite.destroy(error); } else { // In case there's no error, just close the gzip stream streamToWrite.end(); } } else if (error) { responseStream.destroy(error); } }; streamToWrite.on("close", cancel); streamToWrite.on("error", cancel); next(); async function next() { try { for (;;) { const { done, value } = await reader.read(); if (done) break; if (!isBase64Encoded) { const writer = streamToWrite as zlib.Gzip; const result = writer.write(value, () => { writer.flush(zlib.constants.Z_SYNC_FLUSH); }); if (!result) writer.once("drain", next); } else { if (!streamToWrite.write(value)) { streamToWrite.once("drain", next); return; } } } streamToWrite.end(); } catch (error) { cancel(error instanceof Error ? error : new Error(String(error))); } } } function convertToCfResult({ statusCode, headers, cookies, body, isBase64Encoded, }: InternalResult): CloudFrontRequestResult { const combinedHeaders = Object.entries(headers).reduce( (headers, [key, value]) => { headers[key.toLowerCase()] = [{ key, value }]; return headers; }, {} as CloudFrontHeaders ); combinedHeaders["set-cookie"] = stringifyCookies(cookies).map((cookie) => ({ key: "set-cookie", value: cookie, })); const response: CloudFrontRequestResult = { status: statusCode.toString(), statusDescription: "OK", headers: Object.entries(headers).reduce((headers, [key, value]) => { headers[key.toLowerCase()] = [{ key, value }]; return headers; }, {} as CloudFrontHeaders), bodyEncoding: isBase64Encoded ? "base64" : "text", body: body, }; return response; } function normalizeApigV2Headers({ headers, cookies }: APIGatewayProxyEventV2) { const combinedHeaders: Record<string, string> = {}; if (Array.isArray(cookies)) { combinedHeaders["cookie"] = cookies.join("; "); } for (const [key, value] of Object.entries(headers ?? {})) { combinedHeaders[key.toLowerCase()] = value!; } return combinedHeaders; } function normalizeApigV2Body({ body, isBase64Encoded, }: APIGatewayProxyEventV2): Buffer { if (Buffer.isBuffer(body)) { return body; } else if (typeof body === "string") { return Buffer.from(body, isBase64Encoded ? "base64" : "utf8"); } else if (typeof body === "object") { return Buffer.from(JSON.stringify(body)); } return Buffer.from("", "utf8"); } function normalizeApigV1QueryParams({ multiValueQueryStringParameters, queryStringParameters, }: APIGatewayProxyEvent) { const params = new URLSearchParams(); for (const [key, value] of Object.entries( multiValueQueryStringParameters ?? {} )) { if (value !== undefined) { for (const v of value) { params.append(key, v); } } } for (const [key, value] of Object.entries(queryStringParameters ?? {})) { if (value !== undefined) { params.append(key, value); } } const value = params.toString(); return value ?? ""; } function normalizeApigV1Headers({ multiValueHeaders, headers, }: APIGatewayProxyEvent) { const combinedHeaders: Record<string, string> = {}; for (const [key, values] of Object.entries(multiValueHeaders ?? {})) { if (values) { combinedHeaders[key.toLowerCase()] = values.join(","); } } for (const [key, value] of Object.entries(headers ?? {})) { if (value) { combinedHeaders[key.toLowerCase()] = value; } } return combinedHeaders; } function normalizeCfHeaders(event: CloudFrontRequestEvent) { const combinedHeaders: Record<string, string> = {}; for (const [key, values] of Object.entries( event.Records[0].cf.request.headers )) { for (const { value } of values) { if (value) { combinedHeaders[key.toLowerCase()] = value; } } } return combinedHeaders; } function stringifyCookies(cookies: Cookie[]) { return cookies.map( (cookie) => `${cookie.name}=${cookie.value};${Object.entries(cookie) .filter( ([key, value]) => key !== "value" && key !== "name" && typeof value !== "undefined" && value !== false ) .map(([key, value]) => typeof value === "boolean" ? `${key};` : typeof value.toUTCString !== "undefined" ? `${key}=${value.toUTCString()};` : `${key}=${value};` ) .join("")}` ); }