From 66dd0341a2a83e7560bca568e0c6a4a62e19df86 Mon Sep 17 00:00:00 2001 From: Taylor Steele Date: Sun, 22 Feb 2026 13:09:50 -0500 Subject: [PATCH 1/3] fix(aws-lambda): add preview command Export fetch from aws-lambda, aws-lambda-streaming presets Add streaming preview shim --- src/presets/aws-lambda/preset.ts | 17 +++++++++++++++++ .../aws-lambda/runtime/aws-lambda-streaming.ts | 4 ++++ src/presets/aws-lambda/runtime/aws-lambda.ts | 4 ++++ src/presets/aws-lambda/utils.ts | 11 +++++++++++ 4 files changed, 36 insertions(+) create mode 100644 src/presets/aws-lambda/utils.ts diff --git a/src/presets/aws-lambda/preset.ts b/src/presets/aws-lambda/preset.ts index 10fc5ec06e..c5f11ff143 100644 --- a/src/presets/aws-lambda/preset.ts +++ b/src/presets/aws-lambda/preset.ts @@ -1,10 +1,16 @@ +import { resolve } from "pathe"; import { defineNitroPreset } from "../_utils/preset.ts"; +import { writeFile } from "../_utils/fs.ts"; +import { awsLambdaPreviewShim } from "./utils.ts"; export type { AwsLambdaOptions as PresetOptions } from "./types.ts"; const awsLambda = defineNitroPreset( { entry: "./aws-lambda/runtime/aws-lambda", + commands: { + preview: "npx srvx --prod ./", + }, awsLambda: { streaming: false, }, @@ -12,7 +18,18 @@ const awsLambda = defineNitroPreset( "rollup:before": (nitro, rollupConfig) => { if (nitro.options.awsLambda?.streaming) { (rollupConfig.input as string) += "-streaming"; + nitro.options.commands.preview = + "npx srvx --prod --import ./server/aws-lambda-preview-shim.mjs ./"; + } + }, + async compiled(nitro) { + if (!nitro.options.awsLambda?.streaming) { + return; } + await writeFile( + resolve(nitro.options.output.serverDir, "aws-lambda-preview-shim.mjs"), + awsLambdaPreviewShim + ); }, }, }, diff --git a/src/presets/aws-lambda/runtime/aws-lambda-streaming.ts b/src/presets/aws-lambda/runtime/aws-lambda-streaming.ts index bfba0e93f4..9817da0e40 100644 --- a/src/presets/aws-lambda/runtime/aws-lambda-streaming.ts +++ b/src/presets/aws-lambda/runtime/aws-lambda-streaming.ts @@ -51,3 +51,7 @@ async function streamToNodeStream( } writer.end(); } + +export default { + fetch: nitroApp.fetch, +}; diff --git a/src/presets/aws-lambda/runtime/aws-lambda.ts b/src/presets/aws-lambda/runtime/aws-lambda.ts index cfe419c2ca..0dfe164dd2 100644 --- a/src/presets/aws-lambda/runtime/aws-lambda.ts +++ b/src/presets/aws-lambda/runtime/aws-lambda.ts @@ -26,3 +26,7 @@ export async function handler( ...(await awsResponseBody(response)), }; } + +export default { + fetch: nitroApp.fetch, +}; diff --git a/src/presets/aws-lambda/utils.ts b/src/presets/aws-lambda/utils.ts new file mode 100644 index 0000000000..0fed82876d --- /dev/null +++ b/src/presets/aws-lambda/utils.ts @@ -0,0 +1,11 @@ +export const awsLambdaPreviewShim = `globalThis.awslambda ??= { + streamifyResponse(handler) { + return handler; + }, + HttpResponseStream: { + from(stream) { + return stream; + }, + }, +}; +`; From d07b66ecc448fc72c6a3f5ddbba683b2436e9cdc Mon Sep 17 00:00:00 2001 From: Taylor Steele Date: Wed, 6 May 2026 09:59:06 -0400 Subject: [PATCH 2/3] refactor(aws-lambda): add srvx lambda handlers --- src/presets/aws-lambda/preset.ts | 14 --- .../runtime/aws-lambda-streaming.ts | 87 +++++++++---------- src/presets/aws-lambda/runtime/aws-lambda.ts | 31 ++----- src/presets/aws-lambda/utils.ts | 11 --- test/presets/aws-lambda.test.ts | 29 ++++++- test/tests.ts | 14 ++- 6 files changed, 89 insertions(+), 97 deletions(-) delete mode 100644 src/presets/aws-lambda/utils.ts diff --git a/src/presets/aws-lambda/preset.ts b/src/presets/aws-lambda/preset.ts index c5f11ff143..12b1f9aaa3 100644 --- a/src/presets/aws-lambda/preset.ts +++ b/src/presets/aws-lambda/preset.ts @@ -1,7 +1,4 @@ -import { resolve } from "pathe"; import { defineNitroPreset } from "../_utils/preset.ts"; -import { writeFile } from "../_utils/fs.ts"; -import { awsLambdaPreviewShim } from "./utils.ts"; export type { AwsLambdaOptions as PresetOptions } from "./types.ts"; @@ -18,19 +15,8 @@ const awsLambda = defineNitroPreset( "rollup:before": (nitro, rollupConfig) => { if (nitro.options.awsLambda?.streaming) { (rollupConfig.input as string) += "-streaming"; - nitro.options.commands.preview = - "npx srvx --prod --import ./server/aws-lambda-preview-shim.mjs ./"; } }, - async compiled(nitro) { - if (!nitro.options.awsLambda?.streaming) { - return; - } - await writeFile( - resolve(nitro.options.output.serverDir, "aws-lambda-preview-shim.mjs"), - awsLambdaPreviewShim - ); - }, }, }, { diff --git a/src/presets/aws-lambda/runtime/aws-lambda-streaming.ts b/src/presets/aws-lambda/runtime/aws-lambda-streaming.ts index 9817da0e40..f707b7e44c 100644 --- a/src/presets/aws-lambda/runtime/aws-lambda-streaming.ts +++ b/src/presets/aws-lambda/runtime/aws-lambda-streaming.ts @@ -1,57 +1,52 @@ import "#nitro/virtual/polyfills"; import { useNitroApp } from "nitro/app"; -import { awsRequest, awsResponseHeaders } from "./_utils.ts"; - -import type { StreamingResponse } from "@netlify/functions"; -import type { Readable } from "node:stream"; -import type { APIGatewayProxyEventV2 } from "aws-lambda"; +import { + handleLambdaEventWithStream, + invokeLambdaHandler, + toLambdaHandler, + type AWSLambdaHandler, + type AWSLambdaResponseStream, + type AWSLambdaStreamingHandler, + type AwsLambdaEvent, +} from "srvx/aws-lambda"; +import { tracingSrvxPlugins } from "#nitro/virtual/tracing"; + +import type { Context } from "aws-lambda"; const nitroApp = useNitroApp(); -export const handler = awslambda.streamifyResponse( - async (event: APIGatewayProxyEventV2, responseStream, context) => { - const request = awsRequest(event, context); - - const response = await nitroApp.fetch(request); - - const httpResponseMetadata: Omit = { - statusCode: response.status, - ...awsResponseHeaders(response), - }; - - if (!httpResponseMetadata.headers!["transfer-encoding"]) { - httpResponseMetadata.headers!["transfer-encoding"] = "chunked"; - } +const lambdaHandler = toLambdaHandler({ + fetch: nitroApp.fetch, + plugins: [...tracingSrvxPlugins], +}); + +const handlerWithPreviewFallback = (( + event: AwsLambdaEvent, + responseStreamOrContext: AWSLambdaResponseStream | Context, + context?: Context +) => { + if (context) { + return handleLambdaEventWithStream( + nitroApp.fetch, + event, + responseStreamOrContext as AWSLambdaResponseStream, + context + ); + } - const body = - response.body ?? - new ReadableStream({ - start(controller) { - controller.enqueue(""); - controller.close(); - }, - }); + return lambdaHandler(event, responseStreamOrContext as Context); +}) as AWSLambdaStreamingHandler & AWSLambdaHandler; - const writer = awslambda.HttpResponseStream.from(responseStream, httpResponseMetadata); +const awsLambdaGlobal = globalThis as typeof globalThis & { + awslambda?: { + streamifyResponse: (handler: AWSLambdaStreamingHandler) => AWSLambdaStreamingHandler; + }; +}; - const reader = body.getReader(); - await streamToNodeStream(reader, responseStream); - writer.end(); - } -); - -async function streamToNodeStream( - reader: Readable | ReadableStreamDefaultReader, - writer: NodeJS.WritableStream -) { - let readResult = await reader.read(); - while (!readResult.done) { - writer.write(readResult.value); - readResult = await reader.read(); - } - writer.end(); -} +export const handler = awsLambdaGlobal.awslambda?.streamifyResponse + ? awsLambdaGlobal.awslambda.streamifyResponse(handlerWithPreviewFallback) + : handlerWithPreviewFallback; export default { - fetch: nitroApp.fetch, + fetch: (request: Request) => invokeLambdaHandler(lambdaHandler, request), }; diff --git a/src/presets/aws-lambda/runtime/aws-lambda.ts b/src/presets/aws-lambda/runtime/aws-lambda.ts index 0dfe164dd2..ca2ecd2a5a 100644 --- a/src/presets/aws-lambda/runtime/aws-lambda.ts +++ b/src/presets/aws-lambda/runtime/aws-lambda.ts @@ -1,32 +1,15 @@ import "#nitro/virtual/polyfills"; import { useNitroApp } from "nitro/app"; -import { awsRequest, awsResponseHeaders, awsResponseBody } from "./_utils.ts"; - -import type { - APIGatewayProxyEvent, - APIGatewayProxyEventV2, - APIGatewayProxyResult, - APIGatewayProxyResultV2, - Context, -} from "aws-lambda"; +import { invokeLambdaHandler, toLambdaHandler } from "srvx/aws-lambda"; +import { tracingSrvxPlugins } from "#nitro/virtual/tracing"; const nitroApp = useNitroApp(); -export async function handler( - event: APIGatewayProxyEvent | APIGatewayProxyEventV2, - context: Context -): Promise { - const request = awsRequest(event, context); - - const response = await nitroApp.fetch(request); - - return { - statusCode: response.status, - ...awsResponseHeaders(response), - ...(await awsResponseBody(response)), - }; -} +export const handler = toLambdaHandler({ + fetch: nitroApp.fetch, + plugins: [...tracingSrvxPlugins], +}); export default { - fetch: nitroApp.fetch, + fetch: (request: Request) => invokeLambdaHandler(handler, request), }; diff --git a/src/presets/aws-lambda/utils.ts b/src/presets/aws-lambda/utils.ts deleted file mode 100644 index 0fed82876d..0000000000 --- a/src/presets/aws-lambda/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const awsLambdaPreviewShim = `globalThis.awslambda ??= { - streamifyResponse(handler) { - return handler; - }, - HttpResponseStream: { - from(stream) { - return stream; - }, - }, -}; -`; diff --git a/test/presets/aws-lambda.test.ts b/test/presets/aws-lambda.test.ts index 43b4a5f7e9..d68dd22171 100644 --- a/test/presets/aws-lambda.test.ts +++ b/test/presets/aws-lambda.test.ts @@ -1,6 +1,6 @@ import type { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from "aws-lambda"; import { resolve } from "pathe"; -import { describe } from "vitest"; +import { describe, expect, it } from "vitest"; import { parseURL, parseQuery } from "ufo"; import { setupTest, testNitro } from "../tests.ts"; @@ -42,6 +42,14 @@ describe("nitro:preset:aws-lambda-v2", async () => { return webResponse(res); }; }); + + it("exports a preview fetch through the Lambda adapter", async () => { + const entry = await import(resolve(ctx.outDir, "server/index.mjs")); + const response = await entry.default.fetch(new Request("http://localhost/api/hello")); + await expect(response.json()).resolves.toMatchObject({ + message: "Hello API", + }); + }); }); describe("nitro:preset:aws-lambda-v1", async () => { @@ -70,6 +78,25 @@ describe("nitro:preset:aws-lambda-v1", async () => { }); }); +describe("nitro:preset:aws-lambda-streaming", async () => { + const ctx = await setupTest("aws-lambda", { + config: { + awsLambda: { + streaming: true, + }, + }, + outDirSuffix: "-streaming", + }); + + it("exports a preview fetch through the streaming Lambda adapter", async () => { + const entry = await import(resolve(ctx.outDir, "server/index.mjs")); + const response = await entry.default.fetch(new Request("http://localhost/api/hello")); + await expect(response.json()).resolves.toMatchObject({ + message: "Hello API", + }); + }); +}); + function webResponse(awsResponse: any) { const headers = new Headers(awsResponse.headers); const setCookie = diff --git a/test/tests.ts b/test/tests.ts index 1536367130..77e18a266d 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -767,7 +767,7 @@ export function testNitro( }); describe("async context", () => { - it.skipIf(!ctx.nitro!.options.node)("works", async () => { + it.skipIf(!ctx.nitro!.options.node || ctx.lambdaV1)("works", async () => { const { data } = await callHandler({ url: "/context?foo" }); expect(data).toMatchObject({ context: { @@ -775,6 +775,18 @@ export function testNitro( }, }); }); + + it.runIf(ctx.nitro!.options.node && ctx.lambdaV1)( + "works with Lambda v1 query string normalization", + async () => { + const { data } = await callHandler({ url: "/context?foo" }); + expect(data).toMatchObject({ + context: { + path: "/context?foo=", + }, + }); + } + ); }); describe.skipIf(!ctx.supportsEnv)("environment variables", () => { From a6af3e7a5b3d519e00acc39dd8dded6c0513b344 Mon Sep 17 00:00:00 2001 From: Taylor Steele Date: Thu, 14 May 2026 14:05:18 -0400 Subject: [PATCH 3/3] feat(aws-lambda): add SSR support with streaming handler and update fetch exports --- src/build/vite/prod.ts | 6 +- test/presets/aws-lambda.test.ts | 55 +++++++++++++++++++ .../lambda-ssr-fixture/app/entry-server.ts | 9 +++ test/vite/lambda-ssr-fixture/vite.config.ts | 6 ++ 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 test/vite/lambda-ssr-fixture/app/entry-server.ts create mode 100644 test/vite/lambda-ssr-fixture/vite.config.ts diff --git a/src/build/vite/prod.ts b/src/build/vite/prod.ts index 3c20ebc758..300962cfd8 100644 --- a/src/build/vite/prod.ts +++ b/src/build/vite/prod.ts @@ -144,11 +144,11 @@ function lazyService(loader) { let promise, mod return { fetch(req) { - if (mod) { return mod.fetch(req) } + if (mod) { return (mod.fetch || mod.default?.fetch)(req) } if (!promise) { - promise = loader().then(_mod => (mod = _mod.default || _mod)) + promise = loader().then(_mod => (mod = _mod)) } - return promise.then(mod => mod.fetch(req)) + return promise.then(mod => (mod.fetch || mod.default?.fetch)(req)) } } } diff --git a/test/presets/aws-lambda.test.ts b/test/presets/aws-lambda.test.ts index d68dd22171..3e4340ba19 100644 --- a/test/presets/aws-lambda.test.ts +++ b/test/presets/aws-lambda.test.ts @@ -1,5 +1,6 @@ import type { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from "aws-lambda"; import { resolve } from "pathe"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { parseURL, parseQuery } from "ufo"; import { setupTest, testNitro } from "../tests.ts"; @@ -97,6 +98,60 @@ describe("nitro:preset:aws-lambda-streaming", async () => { }); }); +describe("nitro:preset:aws-lambda-streaming-vite-ssr", async () => { + const ctx = await setupTest("aws-lambda", { + config: { + builder: "vite", + rootDir: fileURLToPath(new URL("../vite/lambda-ssr-fixture", import.meta.url)), + awsLambda: { + streaming: true, + }, + }, + outDirSuffix: "-streaming-vite-ssr", + }); + + it("handles SSR services with named fetch exports on the streaming handler path", async () => { + const { handler } = await import(resolve(ctx.outDir, "server/index.mjs")); + const event = { + rawPath: "/", + headers: { + host: "localhost", + }, + rawQueryString: "", + queryStringParameters: {}, + body: "", + isBase64Encoded: false, + version: "2", + routeKey: "", + requestContext: { + accountId: "", + apiId: "", + domainName: "localhost", + domainPrefix: "", + requestId: "", + routeKey: "", + stage: "", + time: "", + timeEpoch: 0, + http: { + path: "/", + protocol: "http", + userAgent: "", + sourceIp: "", + method: "GET", + }, + }, + } satisfies APIGatewayProxyEventV2; + + const response = await handler(event); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toMatchObject({ + ok: true, + }); + }); +}); + function webResponse(awsResponse: any) { const headers = new Headers(awsResponse.headers); const setCookie = diff --git a/test/vite/lambda-ssr-fixture/app/entry-server.ts b/test/vite/lambda-ssr-fixture/app/entry-server.ts new file mode 100644 index 0000000000..eb67891a47 --- /dev/null +++ b/test/vite/lambda-ssr-fixture/app/entry-server.ts @@ -0,0 +1,9 @@ +async function render(_request: Request) { + return Response.json({ + ok: true, + }); +} + +export { render as fetch }; + +export default render; diff --git a/test/vite/lambda-ssr-fixture/vite.config.ts b/test/vite/lambda-ssr-fixture/vite.config.ts new file mode 100644 index 0000000000..dca04a0e8f --- /dev/null +++ b/test/vite/lambda-ssr-fixture/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import { nitro } from "nitro/vite"; + +export default defineConfig({ + plugins: [nitro({ serverDir: "./" })], +});