Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
27 changes: 25 additions & 2 deletions src/build/info.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -48,7 +48,7 @@ export async function writeBuildInfo(
nitro: Nitro,
output: RolldownOutput | RollupOutput | undefined
): Promise<NitroBuildInfo> {
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 = {
Expand Down Expand Up @@ -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;
}
14 changes: 14 additions & 0 deletions src/presets/azure/runtime/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | Buffer | undefined> {
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);
}
Comment thread
tonoizer marked this conversation as resolved.
24 changes: 11 additions & 13 deletions src/presets/azure/runtime/azure-swa.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,36 @@
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
body: req.bufferBody ?? req.rawBody,
});

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")
Expand Down
67 changes: 67 additions & 0 deletions test/unit/azure-swa.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof resolveAzureSwaRequestUrl>[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<typeof resolveAzureSwaRequestUrl>[0])
).toBe("http://nitro.local/api/hello");
});

it("handles empty params.url", () => {
expect(
resolveAzureSwaRequestUrl({
headers: {},
params: {},
} as Parameters<typeof resolveAzureSwaRequestUrl>[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));
});
});
92 changes: 92 additions & 0 deletions test/unit/build-info.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});