diff --git a/.changeset/bright-bulldogs-laugh.md b/.changeset/bright-bulldogs-laugh.md new file mode 100644 index 00000000..a01a8956 --- /dev/null +++ b/.changeset/bright-bulldogs-laugh.md @@ -0,0 +1,7 @@ +--- +"@opennextjs/aws": patch +--- + +Add aws-lambda-compressed wrapper + +New wrapper called `aws-lambda-compressed`. The compression quality for brotli can be configured using the `BROTLI_QUALITY` environment variable. If not set, it defaults to 6. \ No newline at end of file diff --git a/packages/open-next/src/build/validateConfig.ts b/packages/open-next/src/build/validateConfig.ts index acb3b3ae..ecb3cda1 100644 --- a/packages/open-next/src/build/validateConfig.ts +++ b/packages/open-next/src/build/validateConfig.ts @@ -15,6 +15,7 @@ const compatibilityMatrix: Record = { "aws-cloudfront", "sqs-revalidate", ], + "aws-lambda-compressed": ["aws-apigw-v2"], "aws-lambda-streaming": ["aws-apigw-v2"], cloudflare: ["edge"], "cloudflare-edge": ["edge"], diff --git a/packages/open-next/src/overrides/wrappers/aws-lambda-compressed.ts b/packages/open-next/src/overrides/wrappers/aws-lambda-compressed.ts new file mode 100644 index 00000000..80958668 --- /dev/null +++ b/packages/open-next/src/overrides/wrappers/aws-lambda-compressed.ts @@ -0,0 +1,113 @@ +import { Readable, type Transform, Writable } from "node:stream"; +import type { ReadableStream } from "node:stream/web"; +import zlib from "node:zlib"; + +import type { AwsLambdaEvent, AwsLambdaReturn } from "types/aws-lambda"; +import type { InternalResult, StreamCreator } from "types/open-next"; +import type { WrapperHandler } from "types/overrides"; +import { error } from "../../adapters/logger"; +import { formatWarmerResponse } from "./aws-lambda"; + +const handler: WrapperHandler = + async (handler, converter) => + async (event: AwsLambdaEvent): Promise => { + // Handle warmer event + if ("type" in event) { + return formatWarmerResponse(event); + } + + const internalEvent = await converter.convertFrom(event); + // This is a workaround + // https://github.com/opennextjs/opennextjs-aws/blob/e9b37fd44eb856eb8ae73168bf455ff85dd8b285/packages/open-next/src/overrides/wrappers/aws-lambda.ts#L49-L53 + const fakeStream: StreamCreator = { + writeHeaders: () => { + return new Writable({ + write: (_chunk, _encoding, callback) => { + callback(); + }, + }); + }, + }; + + const handlerResponse = await handler(internalEvent, { + streamCreator: fakeStream, + }); + + // Check if response is already compressed + // The handlers response headers are lowercase + const alreadyEncoded = handlerResponse.headers["content-encoding"] ?? ""; + + // Return early here if the response is already compressed + if (alreadyEncoded) { + return converter.convertTo(handlerResponse, event); + } + + // We compress the body if the client accepts it + const acceptEncoding = + internalEvent.headers["accept-encoding"] ?? + internalEvent.headers["Accept-Encoding"] ?? + ""; + + let contentEncoding: string | null = null; + if (acceptEncoding?.includes("br")) { + contentEncoding = "br"; + } else if (acceptEncoding?.includes("gzip")) { + contentEncoding = "gzip"; + } else if (acceptEncoding?.includes("deflate")) { + contentEncoding = "deflate"; + } + + const response: InternalResult = { + ...handlerResponse, + body: compressBody(handlerResponse.body, contentEncoding), + headers: { + ...handlerResponse.headers, + ...(contentEncoding ? { "content-encoding": contentEncoding } : {}), + }, + isBase64Encoded: !!contentEncoding || handlerResponse.isBase64Encoded, + }; + + return converter.convertTo(response, event); + }; + +export default { + wrapper: handler, + name: "aws-lambda-compressed", + supportStreaming: false, +}; + +function compressBody(body: ReadableStream, encoding: string | null) { + // If no encoding is specified, return original body + if (!encoding) return body; + try { + const readable = Readable.fromWeb(body); + let transform: Transform; + + switch (encoding) { + case "br": + transform = zlib.createBrotliCompress({ + params: { + // This is a compromise between speed and compression ratio. + // The default one will most likely timeout an AWS Lambda with default configuration on large bodies (>6mb). + // Therefore we set it to 6, which is a good compromise. + [zlib.constants.BROTLI_PARAM_QUALITY]: + Number(process.env.BROTLI_QUALITY) ?? 6, + }, + }); + break; + case "gzip": + transform = zlib.createGzip(); + break; + case "deflate": + transform = zlib.createDeflate(); + break; + default: + return body; + } + return Readable.toWeb(readable.pipe(transform)); + } catch (e) { + error("Error compressing body:", e); + // Fall back to no compression on error + return body; + } +} diff --git a/packages/open-next/src/overrides/wrappers/aws-lambda.ts b/packages/open-next/src/overrides/wrappers/aws-lambda.ts index 6c45ee07..fed3fb10 100644 --- a/packages/open-next/src/overrides/wrappers/aws-lambda.ts +++ b/packages/open-next/src/overrides/wrappers/aws-lambda.ts @@ -1,34 +1,14 @@ import { Writable } from "node:stream"; -import type { - APIGatewayProxyEvent, - APIGatewayProxyEventV2, - APIGatewayProxyResult, - APIGatewayProxyResultV2, - CloudFrontRequestEvent, - CloudFrontRequestResult, -} from "aws-lambda"; -import type { WrapperHandler } from "types/overrides"; - +import type { AwsLambdaEvent, AwsLambdaReturn } from "types/aws-lambda"; import type { StreamCreator } from "types/open-next"; +import type { WrapperHandler } from "types/overrides"; import type { WarmerEvent, WarmerResponse, } from "../../adapters/warmer-function"; -type AwsLambdaEvent = - | APIGatewayProxyEventV2 - | CloudFrontRequestEvent - | APIGatewayProxyEvent - | WarmerEvent; - -type AwsLambdaReturn = - | APIGatewayProxyResultV2 - | APIGatewayProxyResult - | CloudFrontRequestResult - | WarmerResponse; - -function formatWarmerResponse(event: WarmerEvent) { +export function formatWarmerResponse(event: WarmerEvent) { return new Promise((resolve) => { setTimeout(() => { resolve({ serverId, type: "warmer" } satisfies WarmerResponse); diff --git a/packages/open-next/src/types/aws-lambda.ts b/packages/open-next/src/types/aws-lambda.ts index f99a5412..d2e4de2f 100644 --- a/packages/open-next/src/types/aws-lambda.ts +++ b/packages/open-next/src/types/aws-lambda.ts @@ -1,5 +1,15 @@ import type { Writable } from "node:stream"; -import type { APIGatewayProxyEventV2, Context } from "aws-lambda"; + +import type { + APIGatewayProxyEvent, + APIGatewayProxyEventV2, + APIGatewayProxyResult, + APIGatewayProxyResultV2, + CloudFrontRequestEvent, + CloudFrontRequestResult, + Context, +} from "aws-lambda"; +import type { WarmerEvent, WarmerResponse } from "../adapters/warmer-function"; export interface ResponseStream extends Writable { getBufferedData(): Buffer; @@ -25,3 +35,15 @@ declare global { } } } + +export type AwsLambdaEvent = + | APIGatewayProxyEventV2 + | CloudFrontRequestEvent + | APIGatewayProxyEvent + | WarmerEvent; + +export type AwsLambdaReturn = + | APIGatewayProxyResultV2 + | APIGatewayProxyResult + | CloudFrontRequestResult + | WarmerResponse; diff --git a/packages/open-next/src/types/open-next.ts b/packages/open-next/src/types/open-next.ts index e307912d..17ab7ca0 100644 --- a/packages/open-next/src/types/open-next.ts +++ b/packages/open-next/src/types/open-next.ts @@ -94,6 +94,7 @@ export interface Origin { export type IncludedWrapper = | "aws-lambda" | "aws-lambda-streaming" + | "aws-lambda-compressed" | "node" // @deprecated - use "cloudflare-edge" instead. | "cloudflare" diff --git a/packages/open-next/src/types/overrides.ts b/packages/open-next/src/types/overrides.ts index 4ea25727..81325c8b 100644 --- a/packages/open-next/src/types/overrides.ts +++ b/packages/open-next/src/types/overrides.ts @@ -1,7 +1,6 @@ import type { Readable } from "node:stream"; import type { Meta } from "types/cache"; - import type { BaseEventOrResult, BaseOverride,