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/src/presets/aws-lambda/preset.ts b/src/presets/aws-lambda/preset.ts index 10fc5ec06e..12b1f9aaa3 100644 --- a/src/presets/aws-lambda/preset.ts +++ b/src/presets/aws-lambda/preset.ts @@ -5,6 +5,9 @@ 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, }, diff --git a/src/presets/aws-lambda/runtime/aws-lambda-streaming.ts b/src/presets/aws-lambda/runtime/aws-lambda-streaming.ts index bfba0e93f4..f707b7e44c 100644 --- a/src/presets/aws-lambda/runtime/aws-lambda-streaming.ts +++ b/src/presets/aws-lambda/runtime/aws-lambda-streaming.ts @@ -1,53 +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), - }; +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 + ); + } - if (!httpResponseMetadata.headers!["transfer-encoding"]) { - httpResponseMetadata.headers!["transfer-encoding"] = "chunked"; - } + return lambdaHandler(event, responseStreamOrContext as Context); +}) as AWSLambdaStreamingHandler & AWSLambdaHandler; - const body = - response.body ?? - new ReadableStream({ - start(controller) { - controller.enqueue(""); - controller.close(); - }, - }); +const awsLambdaGlobal = globalThis as typeof globalThis & { + awslambda?: { + streamifyResponse: (handler: AWSLambdaStreamingHandler) => AWSLambdaStreamingHandler; + }; +}; - const writer = awslambda.HttpResponseStream.from(responseStream, httpResponseMetadata); +export const handler = awsLambdaGlobal.awslambda?.streamifyResponse + ? awsLambdaGlobal.awslambda.streamifyResponse(handlerWithPreviewFallback) + : handlerWithPreviewFallback; - 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 default { + 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 cfe419c2ca..ca2ecd2a5a 100644 --- a/src/presets/aws-lambda/runtime/aws-lambda.ts +++ b/src/presets/aws-lambda/runtime/aws-lambda.ts @@ -1,28 +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); +export const handler = toLambdaHandler({ + fetch: nitroApp.fetch, + plugins: [...tracingSrvxPlugins], +}); - return { - statusCode: response.status, - ...awsResponseHeaders(response), - ...(await awsResponseBody(response)), - }; -} +export default { + fetch: (request: Request) => invokeLambdaHandler(handler, request), +}; diff --git a/test/presets/aws-lambda.test.ts b/test/presets/aws-lambda.test.ts index 43b4a5f7e9..3e4340ba19 100644 --- a/test/presets/aws-lambda.test.ts +++ b/test/presets/aws-lambda.test.ts @@ -1,6 +1,7 @@ import type { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from "aws-lambda"; import { resolve } from "pathe"; -import { describe } from "vitest"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; import { parseURL, parseQuery } from "ufo"; import { setupTest, testNitro } from "../tests.ts"; @@ -42,6 +43,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 +79,79 @@ 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", + }); + }); +}); + +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/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", () => { 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: "./" })], +});