Skip to content

add aws-lambda-compressed #819

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 10, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/bright-bulldogs-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@opennextjs/aws": patch
---

Add aws-lambda-compressed wrapper

Introduces a new wrapper called `aws-lambda-compressed`. Will compress the response body by default. Compression will be applied in the following priority order: br (Brotli) → gzip → deflate. If none of these is found, we just return the body as is.

The compression quality for brotli can be configured using the `BROTLI_QUALITY` environment variable. If not set, it defaults to 6.
1 change: 1 addition & 0 deletions packages/open-next/src/build/validateConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const compatibilityMatrix: Record<IncludedWrapper, IncludedConverter[]> = {
"aws-cloudfront",
"sqs-revalidate",
],
"aws-lambda-compressed": ["aws-apigw-v2"],
"aws-lambda-streaming": ["aws-apigw-v2"],
cloudflare: ["edge"],
"cloudflare-edge": ["edge"],
Expand Down
136 changes: 136 additions & 0 deletions packages/open-next/src/overrides/wrappers/aws-lambda-compressed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { Readable, Writable } from "node:stream";
import type { ReadableStream } from "node:stream/web";
import zlib from "node:zlib";

import type {
APIGatewayProxyEvent,
APIGatewayProxyEventV2,
APIGatewayProxyResult,
APIGatewayProxyResultV2,
CloudFrontRequestEvent,
CloudFrontRequestResult,
} from "aws-lambda";
import type { WrapperHandler } from "types/overrides";

import type { InternalResult, StreamCreator } from "types/open-next";
import { error } from "../../adapters/logger";
import type {
WarmerEvent,
WarmerResponse,
} from "../../adapters/warmer-function";

type AwsLambdaEvent =
| APIGatewayProxyEventV2
| CloudFrontRequestEvent
| APIGatewayProxyEvent
| WarmerEvent;

type AwsLambdaReturn =
| APIGatewayProxyResultV2
| APIGatewayProxyResult
| CloudFrontRequestResult
| WarmerResponse;

function formatWarmerResponse(event: WarmerEvent) {
return new Promise<WarmerResponse>((resolve) => {
setTimeout(() => {
resolve({ serverId, type: "warmer" } satisfies WarmerResponse);
}, event.delay);
});
}

const handler: WrapperHandler =
async (handler, converter) =>
async (event: AwsLambdaEvent): Promise<AwsLambdaReturn> => {
// Handle warmer event
if ("type" in event) {
return formatWarmerResponse(event);
}

const internalEvent = await converter.convertFrom(event);
//TODO: create a simple reproduction and open an issue in the node repo
//This is a workaround, there is an issue in node that causes node to crash silently if the OpenNextNodeResponse stream is not consumed
//This does not happen everytime, it's probably caused by suspended component in ssr (either via <Suspense> or loading.tsx)
//Everyone that wish to create their own wrapper without a StreamCreator should implement this workaround
//This is not necessary if the underlying handler does not use OpenNextNodeResponse (At the moment, OpenNextNodeResponse is used by the node runtime servers and the image server)
const fakeStream: StreamCreator = {
writeHeaders: () => {
return new Writable({
write: (_chunk, _encoding, callback) => {
callback();
},
});
},
};

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 handlerResponse = await handler(internalEvent, {
streamCreator: fakeStream,
});

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);

switch (encoding) {
case "br":
return Readable.toWeb(
readable.pipe(
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,
},
}),
),
);
case "gzip":
return Readable.toWeb(readable.pipe(zlib.createGzip()));
case "deflate":
return Readable.toWeb(readable.pipe(zlib.createDeflate()));
default:
return body;
}
} catch (e) {
error("Error compressing body:", e);
// Fall back to no compression on error
return body;
}
}
1 change: 1 addition & 0 deletions packages/open-next/src/types/open-next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading