UNPKG

astro-sst

Version:

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

334 lines (333 loc) 11.1 kB
import { splitCookiesString, parse } from "set-cookie-parser"; import { isBinaryContentType } from "./binary.js"; import zlib from "zlib"; function isApigV2Event(event) { return event.version === "2.0"; } function isApigV1Event(event) { return event.version === undefined && !isCfEvent(event); } function isCfEvent(event) { return event.Records !== undefined; } export function convertFrom(event) { let iEvent; 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 }; } function convertFromApigV1Event(event) { const { path, body, httpMethod, requestContext, isBase64Encoded } = event; const headers = normalizeApigV1Headers(event); return { type: "v1", method: httpMethod, rawPath: path, queryString: normalizeApigV1QueryParams(event), body: Buffer.from(body ?? "", isBase64Encoded ? "base64" : "utf8"), headers, remoteAddress: requestContext.identity.sourceIp, }; } function convertFromApigV2Event(event) { const { rawPath, rawQueryString, requestContext } = event; return { type: "v2", method: requestContext.http.method, rawPath, queryString: rawQueryString, body: normalizeApigV2Body(event), headers: normalizeApigV2Headers(event), remoteAddress: requestContext.http.sourceIp, }; } function convertFromCfEvent(event) { const { method, uri, querystring, body, clientIp } = event.Records[0].cf.request; return { type: "cf", 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, }) { // Parse headers (except cookies) const headers = Array.from(response.headers.entries()) .filter(([key]) => key !== "set-cookie") .reduce((headers, [key, value]) => { headers[key] = value; return headers; }, {}); // 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, }) { const multiValueHeaders = {}; if (cookies.length > 0) { multiValueHeaders["set-cookie"] = stringifyCookies(cookies); } const response = { statusCode, headers, multiValueHeaders, body, isBase64Encoded, }; return response; } function convertToApigV2Result({ headers, statusCode, body, isBase64Encoded, cookies, }) { const response = { statusCode, headers, cookies: cookies.length > 0 ? stringifyCookies(cookies) : undefined, body, isBase64Encoded, }; return response; } function convertToApigV2StreamingResult({ statusCode, headers, cookies, body, responseStream, isBase64Encoded, }) { 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; if (!isBase64Encoded) { const gzip = zlib.createGzip(); gzip.pipe(responseStream); streamToWrite = gzip; } else { streamToWrite = responseStream; } const cancel = (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.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; 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, }) { const combinedHeaders = Object.entries(headers).reduce((headers, [key, value]) => { headers[key.toLowerCase()] = [{ key, value }]; return headers; }, {}); combinedHeaders["set-cookie"] = stringifyCookies(cookies).map((cookie) => ({ key: "set-cookie", value: cookie, })); const response = { status: statusCode.toString(), statusDescription: "OK", headers: Object.entries(headers).reduce((headers, [key, value]) => { headers[key.toLowerCase()] = [{ key, value }]; return headers; }, {}), bodyEncoding: isBase64Encoded ? "base64" : "text", body: body, }; return response; } function normalizeApigV2Headers({ headers, cookies }) { const combinedHeaders = {}; 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, }) { 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, }) { 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, }) { const combinedHeaders = {}; 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) { const combinedHeaders = {}; 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) { 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("")}`); }