Skip to content
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