diff --git a/docs/2.deploy/20.providers/azure.md b/docs/2.deploy/20.providers/azure.md index fad5de1b5a..49750f9bd9 100644 --- a/docs/2.deploy/20.providers/azure.md +++ b/docs/2.deploy/20.providers/azure.md @@ -14,6 +14,14 @@ Integration with this provider is possible with [zero configuration](/deploy#zer [Azure Static Web Apps](https://azure.microsoft.com/en-us/products/app-service/static) are designed to be deployed continuously in a [GitHub Actions workflow](https://docs.microsoft.com/en-us/azure/static-web-apps/github-actions-workflow). Nitro detects this deployment environment and enables the `azure_swa` preset automatically. +The server is deployed as a managed Azure Function using the [Node.js v4 programming model](https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node?pivots=nodejs-model-v4). This requires `@azure/functions` v4 in your project dependencies (Nitro offers to install it if it is missing). It is traced into the build output, so it does not need to be installed again during deployment. + +:pm-install{name="@azure/functions@^4"} + +::note +If you have `@azure/functions` v3 installed, upgrade it to v4. Version 3 uses the legacy programming model (`function.json`) and is no longer supported by this preset. +:: + ### Local preview To test locally, install [Azure Functions Core Tools](https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local), then build and start a local preview environment: @@ -33,7 +41,7 @@ Nitro sets the following properties automatically: | Property | Criteria | Default | | --- | --- | --- | -| **[platform.apiRuntime](https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#platform)** | Set to `node:20` or `node:22` based on the `engines.node` field in your `package.json`. | `node:20` | +| **[platform.apiRuntime](https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#platform)** | Set to `node:20` or `node:22` based on the `engines.node` field in your `package.json`. | `node:22` | | **[navigationFallback.rewrite](https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#fallback-routes)** | Always `/api/server` | `/api/server` | | **[routes](https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#routes)** | All prerendered routes are added. Additionally, if you do not have an `index.html` file, an empty one is created for compatibility purposes, and requests to `/index.html` are redirected to the root directory (handled by `/api/server`). | `[]` | diff --git a/package.json b/package.json index dbc16a8064..1b0ab9f4a1 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ }, "devDependencies": { "@apphosting/common": "^0.0.9", - "@azure/functions": "^3.6.0", + "@azure/functions": "^4.16.5", "@azure/static-web-apps-cli": "^2.0.10", "@cloudflare/workers-types": "^5.20260930.2", "@cloudflare/workers-utils": "^0.45.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a82cb28b3a..efdc4d730b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,8 +49,8 @@ importers: specifier: ^0.0.9 version: 0.0.9 '@azure/functions': - specifier: ^3.6.0 - version: 3.6.0 + specifier: ^4.16.5 + version: 4.16.5 '@azure/static-web-apps-cli': specifier: ^2.0.10 version: 2.0.10(debug@4.4.3)(supports-color@10.2.2) @@ -887,9 +887,13 @@ packages: resolution: {integrity: sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==} engines: {node: '>=22.0.0'} - '@azure/functions@3.6.0': - resolution: {integrity: sha512-mHTygrJgOC0tveNUeDJIwgLVo+3m8+LUlEuGeIAilP0oXBIu4E4IZWmIaxS1as6ytJRYy1WJHDRUtwuV/NeoAQ==} - engines: {node: '>=22.0'} + '@azure/functions-extensions-base@0.3.0': + resolution: {integrity: sha512-Cux0hLu5ZXlC/Kb+yvJVhRLIdkfFwui2HeT5oGZL00r/GCUUkhGTzRfZUjRN4Bq729mPv3okPucz2z7SMQLStA==} + engines: {node: '>=18.0'} + + '@azure/functions@4.16.5': + resolution: {integrity: sha512-h0lVUeI/Q36SyE2xGQOXZqBbmSgQHNLk8pvMxhq3Dw1TaT3eGWs2X25dceGJ153aXXkjcTEdkxg3D2Gcz1uJ0w==} + engines: {node: '>=20.0'} '@azure/identity@4.13.3': resolution: {integrity: sha512-zGQPtqvXPgSA8yfV2CkIQ1qirqk0p9AIVpC5uEkdXQYcKl07QHvyaGYRnZOk0AsQUmxNb4wfkcwY5di8Z5xa9A==} @@ -5061,10 +5065,6 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -5513,9 +5513,6 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} - long@4.0.0: - resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} - longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -7580,10 +7577,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/functions@3.6.0': + '@azure/functions-extensions-base@0.3.0': {} + + '@azure/functions@4.16.5': dependencies: - iconv-lite: 0.6.3 - long: 4.0.0 + '@azure/functions-extensions-base': 0.3.0 + cookie: 0.7.2 '@azure/identity@4.13.3(supports-color@10.2.2)': dependencies: @@ -11746,10 +11745,6 @@ snapshots: human-signals@8.0.1: {} - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -12099,8 +12094,6 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 - long@4.0.0: {} - longest-streak@3.1.0: {} lowlight@3.3.0: diff --git a/src/presets/azure/preset.ts b/src/presets/azure/preset.ts index 816bc3654d..45ece92cf5 100644 --- a/src/presets/azure/preset.ts +++ b/src/presets/azure/preset.ts @@ -1,12 +1,13 @@ import { defineNitroPreset } from "../_utils/preset.ts"; import type { Nitro } from "nitro/types"; -import { writeSWARoutes } from "./utils.ts"; +import { ensureAzureFunctions, writeSWARoutes } from "./utils.ts"; export type { AzureOptions as PresetOptions } from "./types.ts"; const azureSWA = defineNitroPreset( { entry: "./azure/runtime/azure-swa", + traceDeps: ["@azure/functions"], output: { serverDir: "{{ output.dir }}/server/functions", publicDir: "{{ output.dir }}/public/{{ baseURL }}", @@ -15,6 +16,9 @@ const azureSWA = defineNitroPreset( preview: "npx @azure/static-web-apps-cli start ./public --api-location ./server", }, hooks: { + async "build:before"(nitro: Nitro) { + await ensureAzureFunctions(nitro); + }, async compiled(ctx: Nitro) { await writeSWARoutes(ctx); }, diff --git a/src/presets/azure/runtime/_utils.ts b/src/presets/azure/runtime/_utils.ts index 4551c89167..5f8eec15a7 100644 --- a/src/presets/azure/runtime/_utils.ts +++ b/src/presets/azure/runtime/_utils.ts @@ -1,4 +1,4 @@ -import type { Cookie, HttpRequest } from "@azure/functions"; +import type { Cookie } from "@azure/functions"; import { parse } from "cookie-es"; export function getAzureParsedCookiesFromHeaders(headers: Headers): Cookie[] { @@ -32,10 +32,10 @@ export function getAzureParsedCookiesFromHeaders(headers: Headers): Cookie[] { return azureCookies; } -export function resolveBaseUrl(req: HttpRequest) { - const forwardedProto = req.headers["x-forwarded-proto"]; - const forwardedHost = req.headers["x-forwarded-host"]; - const host = forwardedHost || req.headers["host"]; +export function resolveBaseUrl(headers: Headers) { + const forwardedProto = headers.get("x-forwarded-proto"); + const forwardedHost = headers.get("x-forwarded-host"); + const host = forwardedHost || headers.get("host"); if (host) { const candidate = `${forwardedProto || "http"}://${host}`; try { @@ -47,7 +47,7 @@ export function resolveBaseUrl(req: HttpRequest) { }); } } - const originalUrl = req.headers["x-ms-original-url"]; + const originalUrl = headers.get("x-ms-original-url"); if (originalUrl) { try { return new URL(originalUrl).origin; diff --git a/src/presets/azure/runtime/azure-swa.ts b/src/presets/azure/runtime/azure-swa.ts index 9d4e178da1..b0a68c3a84 100644 --- a/src/presets/azure/runtime/azure-swa.ts +++ b/src/presets/azure/runtime/azure-swa.ts @@ -1,42 +1,39 @@ import "#nitro/virtual/polyfills"; +import { app } from "@azure/functions"; import { parseURL } from "ufo"; import { useNitroApp } from "nitro/app"; import { getAzureParsedCookiesFromHeaders, resolveBaseUrl } from "./_utils.ts"; -import type { HttpRequest, HttpResponse, HttpResponseSimple } from "@azure/functions"; +import type { HttpRequest, HttpResponseInit } from "@azure/functions"; const nitroApp = useNitroApp(); -export async function handle(context: { res: HttpResponse }, req: HttpRequest) { - let url: 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 || ""); - } +// https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node?pivots=nodejs-model-v4 +app.http("server", { + route: "{*url}", + methods: ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"], + authLevel: "anonymous", + handler: handle, +}); - const request = new Request(new URL(url, resolveBaseUrl(req)), { - method: req.method || undefined, - headers: new Headers(req.headers), - // https://github.com/Azure/azure-functions-nodejs-worker/issues/294 - // https://github.com/Azure/azure-functions-host/issues/293 - body: req.bufferBody ?? req.rawBody, +async function handle(req: HttpRequest): Promise { + // Proxied requests (no matching static file) carry the original URL in `x-ms-original-url` + const { pathname, search } = parseURL(req.headers.get("x-ms-original-url") || req.url); + + const request = new Request(new URL(pathname + search, resolveBaseUrl(req.headers)), { + method: req.method, + headers: req.headers, + body: req.body ? await req.arrayBuffer() : undefined, }); const response = await nitroApp.fetch(request); - // (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 = { + return { status: response.status, - body: response.body ? Buffer.from(await response.arrayBuffer()) : undefined, + body: response.body, cookies: getAzureParsedCookiesFromHeaders(response.headers), headers: Object.fromEntries( [...response.headers.entries()].filter(([key]) => key !== "set-cookie") ), - } satisfies HttpResponseSimple; + }; } diff --git a/src/presets/azure/utils.ts b/src/presets/azure/utils.ts index 484a0729a1..4ad13bc304 100644 --- a/src/presets/azure/utils.ts +++ b/src/presets/azure/utils.ts @@ -1,8 +1,38 @@ import fsp from "node:fs/promises"; +import { resolveModulePath } from "exsolve"; import { writeFile } from "../_utils/fs.ts"; +import { ensureDep } from "../../utils/dep.ts"; import type { Nitro } from "nitro/types"; import { join, resolve } from "pathe"; +export async function ensureAzureFunctions(nitro: Nitro) { + const reason = "the `azure-swa` preset"; + const resolved = await ensureDep({ + id: "@azure/functions", + dir: nitro.options.rootDir, + reason, + dev: false, + projectOnly: true, + }); + if (!resolved) { + throw new Error( + `\`@azure/functions@^4\` is not installed. Please add it to your dependencies for ${reason}.` + ); + } + const pkgPath = resolveModulePath("@azure/functions/package.json", { + from: nitro.options.rootDir, + try: true, + }); + const version: string | undefined = pkgPath + ? JSON.parse(await fsp.readFile(pkgPath, "utf8")).version + : undefined; + if (version && Number.parseInt(version, 10) < 4) { + throw new Error( + `\`@azure/functions@${version}\` is installed, but ${reason} requires \`@azure/functions@^4\` (Node.js v4 programming model). Please update it in your dependencies.` + ); + } +} + export async function writeSWARoutes(nitro: Nitro) { const host = { version: "2.0", @@ -10,7 +40,7 @@ export async function writeSWARoutes(nitro: Nitro) { // https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node?tabs=typescript%2Cwindows%2Cazure-cli&pivots=nodejs-model-v4#supported-versions const supportedNodeVersions = new Set(["20", "22"]); - let nodeVersion = "18"; + let nodeVersion = "22"; try { const currentNodeVersion = JSON.parse( await fsp.readFile(join(nitro.options.rootDir, "package.json"), "utf8") @@ -106,35 +136,20 @@ export async function writeSWARoutes(nitro: Nitro) { } } - const functionDefinition = { - entryPoint: "handle", - bindings: [ - { - authLevel: "anonymous", - type: "httpTrigger", - direction: "in", - name: "req", - route: "{*url}", - methods: ["delete", "get", "head", "options", "patch", "post", "put"], - }, - { - type: "http", - direction: "out", - name: "res", - }, - ], - }; - - await writeFile( - resolve(nitro.options.output.serverDir, "function.json"), - JSON.stringify(functionDefinition, null, 2) - ); await writeFile( resolve(nitro.options.output.serverDir, "../host.json"), JSON.stringify(host, null, 2) ); - const stubPackageJson = resolve(nitro.options.output.serverDir, "../package.json"); - await writeFile(stubPackageJson, JSON.stringify({ private: true })); + const serverPackageJson = resolve(nitro.options.output.serverDir, "../package.json"); + await writeFile( + serverPackageJson, + JSON.stringify({ private: true, type: "module", main: "functions/index.mjs" }, null, 2) + ); + // Used by Azure Functions Core Tools (`func` / `swa start`) for local preview + await writeFile( + resolve(nitro.options.output.serverDir, "../local.settings.json"), + JSON.stringify({ IsEncrypted: false, Values: { FUNCTIONS_WORKER_RUNTIME: "node" } }, null, 2) + ); await writeFile( resolve(nitro.options.rootDir, "staticwebapp.config.json"), JSON.stringify(config, null, 2) diff --git a/test/unit/azure.utils.test.ts b/test/unit/azure.utils.test.ts index 13a805d15d..1f2a9eabde 100644 --- a/test/unit/azure.utils.test.ts +++ b/test/unit/azure.utils.test.ts @@ -77,7 +77,7 @@ describe("getAzureParsedCookiesFromHeaders", () => { }); describe("resolveBaseUrl", () => { - const req = (headers: Record) => ({ headers }) as any; + const req = (headers: Record) => new Headers(headers); it("uses the forwarded proto and host", () => { expect(