diff --git a/src/build/info.ts b/src/build/info.ts index 31f1a8131e..4eba58213f 100644 --- a/src/build/info.ts +++ b/src/build/info.ts @@ -1,5 +1,5 @@ import type { Nitro, NitroBuildInfo, WorkerAddress } from "nitro/types"; -import { join, relative, resolve } from "pathe"; +import { extname, join, relative, resolve } from "pathe"; import { version as nitroVersion } from "nitro/meta"; import { presetsWithConfig } from "../presets/_types.gen.ts"; import { writeFile } from "../utils/fs.ts"; @@ -48,7 +48,7 @@ export async function writeBuildInfo( nitro: Nitro, output: RolldownOutput | RollupOutput | undefined ): Promise { - const serverEntryName = output?.output?.find((o) => o.type === "chunk" && o.isEntry)?.fileName; + const serverEntryName = resolveNitroServerEntryFileName(output, nitro.options.entry); const buildInfoPath = resolve(nitro.options.output.dir, "nitro.json"); const buildInfo: NitroBuildInfo = { @@ -103,3 +103,26 @@ export async function writeDevBuildInfo(nitro: Nitro, addr?: WorkerAddress): Pro }; await writeFile(buildInfoPath, JSON.stringify(buildInfo, null, 2)); } + +function resolveNitroServerEntryFileName( + output: RolldownOutput | RollupOutput | undefined, + nitroEntry: string +): string | undefined { + return ( + output?.output.find( + (entry) => + entry.type === "chunk" && entry.isEntry && isNitroEntry(entry.facadeModuleId, nitroEntry) + ) ?? + output?.output.find( + (entry) => entry.type === "chunk" && entry.isEntry && entry.fileName === "index.mjs" + ) ?? + output?.output.find((entry) => entry.type === "chunk" && entry.isEntry) + )?.fileName; +} + +function isNitroEntry(facadeModuleId: string | null, nitroEntry: string): boolean { + if (!facadeModuleId) return false; + if (facadeModuleId === nitroEntry) return true; + const extension = extname(facadeModuleId); + return !!extension && facadeModuleId.slice(0, -extension.length) === nitroEntry; +} diff --git a/src/presets/azure/runtime/_utils.ts b/src/presets/azure/runtime/_utils.ts index 919e959c56..5cd2036ad9 100644 --- a/src/presets/azure/runtime/_utils.ts +++ b/src/presets/azure/runtime/_utils.ts @@ -54,3 +54,17 @@ function parseNumber(maxAge: string) { return maxAgeAsNumber; } } + +// Azure Functions v3 expects string | Buffer, not a ReadableStream. +export async function azureResponseBody(response: Response): Promise { + if (!response.body) { + return undefined; + } + const buffer = Buffer.from(await new Response(response.body).arrayBuffer()); + const contentType = response.headers.get("content-type") || ""; + return isTextType(contentType) ? buffer.toString("utf8") : buffer; +} + +function isTextType(contentType = "") { + return /^text\/|\/(javascript|json|xml)|\+(json|xml)|utf-?8/i.test(contentType); +} diff --git a/src/presets/azure/runtime/azure-swa.ts b/src/presets/azure/runtime/azure-swa.ts index de7cf65860..e4b53ed5dd 100644 --- a/src/presets/azure/runtime/azure-swa.ts +++ b/src/presets/azure/runtime/azure-swa.ts @@ -1,25 +1,22 @@ import "#nitro/virtual/polyfills"; -import { parseURL } from "ufo"; import { useNitroApp } from "nitro/app"; -import { getAzureParsedCookiesFromHeaders } from "./_utils.ts"; +import { azureResponseBody, getAzureParsedCookiesFromHeaders } from "./_utils.ts"; import type { HttpRequest, HttpResponse, HttpResponseSimple } from "@azure/functions"; const nitroApp = useNitroApp(); -export async function handle(context: { res: HttpResponse }, req: HttpRequest) { - let url: string; +/** `new Request()` requires an absolute URL; Azure SWA only provides relative paths. */ +export function resolveAzureSwaRequestUrl(req: HttpRequest): string { if (req.headers["x-ms-original-url"]) { - // This URL has been proxied as there was no static file matching it. - const parsedURL = parseURL(req.headers["x-ms-original-url"]); - url = parsedURL.pathname + parsedURL.search; - } else { - // Because Azure SWA handles /api/* calls differently they - // never hit the proxy and we have to reconstitute the URL. - url = "/api/" + (req.params.url || ""); + return req.headers["x-ms-original-url"]; } + // /api/* calls never hit the SWA proxy, so we reconstitute the URL. + return new URL("/api/" + (req.params.url || ""), "http://nitro.local").href; +} - const request = new Request(url, { +export async function handle(context: { res: HttpResponse }, req: HttpRequest) { + const request = new Request(resolveAzureSwaRequestUrl(req), { method: req.method || undefined, // https://github.com/Azure/azure-functions-nodejs-worker/issues/294 // https://github.com/Azure/azure-functions-host/issues/293 @@ -27,12 +24,13 @@ export async function handle(context: { res: HttpResponse }, req: HttpRequest) { }); const response = await nitroApp.fetch(request); + const body = await azureResponseBody(response); // (v3 - current) https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node?tabs=typescript%2Cwindows%2Cazure-cli&pivots=nodejs-model-v3#http-response // (v4) https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node?tabs=typescript%2Cwindows%2Cazure-cli&pivots=nodejs-model-v4#http-response context.res = { status: response.status, - body: response.body, + body, cookies: getAzureParsedCookiesFromHeaders(response.headers), headers: Object.fromEntries( [...response.headers.entries()].filter(([key]) => key !== "set-cookie") diff --git a/test/unit/azure-swa.test.ts b/test/unit/azure-swa.test.ts new file mode 100644 index 0000000000..39fcf6da66 --- /dev/null +++ b/test/unit/azure-swa.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("#nitro/virtual/polyfills", () => ({})); +vi.mock("nitro/app", () => ({ useNitroApp: () => ({}) })); + +import { resolveAzureSwaRequestUrl } from "../../src/presets/azure/runtime/azure-swa.ts"; +import { azureResponseBody } from "../../src/presets/azure/runtime/_utils.ts"; + +describe("resolveAzureSwaRequestUrl", () => { + it("uses x-ms-original-url as-is when present", () => { + expect( + resolveAzureSwaRequestUrl({ + headers: { "x-ms-original-url": "https://example.com/foo?bar=1" }, + params: {}, + } as unknown as Parameters[0]) + ).toBe("https://example.com/foo?bar=1"); + }); + + it("builds an absolute URL for direct /api/* invocations", () => { + expect( + resolveAzureSwaRequestUrl({ + headers: {}, + params: { url: "hello" }, + } as unknown as Parameters[0]) + ).toBe("http://nitro.local/api/hello"); + }); + + it("handles empty params.url", () => { + expect( + resolveAzureSwaRequestUrl({ + headers: {}, + params: {}, + } as Parameters[0]) + ).toBe("http://nitro.local/api/"); + }); +}); + +describe("azureResponseBody", () => { + it("returns undefined when response has no body", async () => { + const response = new Response(null, { status: 204 }); + await expect(azureResponseBody(response)).resolves.toBeUndefined(); + }); + + it("returns text for text content types", async () => { + const response = new Response("hello", { + headers: { "content-type": "text/plain; charset=utf-8" }, + }); + await expect(azureResponseBody(response)).resolves.toBe("hello"); + }); + + it("returns text for structured +json content types", async () => { + const response = new Response('{"title":"Bad Request"}', { + headers: { "content-type": "application/problem+json; charset=utf-8" }, + }); + await expect(azureResponseBody(response)).resolves.toBe('{"title":"Bad Request"}'); + }); + + it("returns a Buffer for binary content types", async () => { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const response = new Response(bytes, { + headers: { "content-type": "image/png" }, + }); + const body = await azureResponseBody(response); + expect(body).toBeInstanceOf(Buffer); + expect(body).toEqual(Buffer.from(bytes)); + }); +}); diff --git a/test/unit/build-info.test.ts b/test/unit/build-info.test.ts new file mode 100644 index 0000000000..7bf04100fb --- /dev/null +++ b/test/unit/build-info.test.ts @@ -0,0 +1,92 @@ +import type { Nitro } from "nitro/types"; +import type { RollupOutput } from "rollup"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("nitro/meta", () => ({ version: "0.0.0" })); +vi.mock("nitro/types", () => ({})); +vi.mock("node:fs/promises", () => ({ mkdir: vi.fn(), readFile: vi.fn(), stat: vi.fn() })); +vi.mock("../../src/utils/fs.ts", () => ({ writeFile: vi.fn() })); + +import { writeBuildInfo } from "../../src/build/info.ts"; +import { writeFile } from "../../src/utils/fs.ts"; + +describe("writeBuildInfo", () => { + afterEach(() => { + vi.mocked(writeFile).mockClear(); + }); + + it("records Nitro's entry when a plugin entry appears first", async () => { + const buildInfo = await writeBuildInfo( + { + options: { + rootDir: "/project", + entry: "/project/server", + output: { + dir: "/project/.output", + serverDir: "/project/.output/server", + publicDir: "/project/.output/public", + }, + commands: {}, + }, + } as Nitro, + { + output: [ + { + type: "chunk", + isEntry: true, + fileName: "plugin-entry.mjs", + facadeModuleId: "/plugin/entry.ts", + }, + { + type: "chunk", + isEntry: true, + fileName: "index.mjs", + facadeModuleId: "/project/server.ts", + }, + ], + } as unknown as RollupOutput + ); + + expect(buildInfo.serverEntry).toBe("server/index.mjs"); + expect(writeFile).toHaveBeenCalledWith( + "/project/.output/nitro.json", + expect.stringContaining('"serverEntry": "server/index.mjs"'), + true + ); + }); + + it("records Nitro's entry when a preset uses a non-default file name", async () => { + const buildInfo = await writeBuildInfo( + { + options: { + rootDir: "/project", + entry: "/project/server", + output: { + dir: "/project/.output", + serverDir: "/project/.output/server", + publicDir: "/project/.output/public", + }, + commands: {}, + }, + } as Nitro, + { + output: [ + { + type: "chunk", + isEntry: true, + fileName: "plugin-entry.mjs", + facadeModuleId: "/plugin/entry.ts", + }, + { + type: "chunk", + isEntry: true, + fileName: "worker.mjs", + facadeModuleId: "/project/server.ts", + }, + ], + } as unknown as RollupOutput + ); + + expect(buildInfo.serverEntry).toBe("server/worker.mjs"); + }); +});