diff --git a/docs/2.deploy/20.providers/azure.md b/docs/2.deploy/20.providers/azure.md index 9cc141cf4b..c2d2ac1ffd 100644 --- a/docs/2.deploy/20.providers/azure.md +++ b/docs/2.deploy/20.providers/azure.md @@ -104,6 +104,14 @@ az functionapp deployment source config-zip -g -n -- cd dist && func azure functionapp publish --javascript ``` +### Building for Azure Functions version 4.x + +To build for the Azure Functions runtime version 4.x, set the Nitro compatibility environment variable to 2024-05-29. + +```bash +NITRO_PRESET=azure_functions NITRO_COMPATIBILITY_DATE=2024-05-29 npx nypm@latest build +``` + ### Deploy from CI/CD via GitHub actions First, obtain your Azure Functions Publish Profile and add it as a secret to your GitHub repository settings following [these instructions](https://github.com/Azure/functions-action#using-publish-profile-as-deployment-credential-recommended). diff --git a/package.json b/package.json index 9258d42b75..8daac5b74f 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,7 @@ "unwasm": "^0.3.9" }, "devDependencies": { - "@azure/functions": "^3.5.1", + "@azure/functions": "^4.5.0", "@azure/static-web-apps-cli": "^1.1.8", "@biomejs/biome": "1.7.3", "@cloudflare/workers-types": "^4.20240512.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0b39c53c2..779ae8b9a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -221,8 +221,8 @@ importers: version: 0.3.9 devDependencies: '@azure/functions': - specifier: ^3.5.1 - version: 3.5.1 + specifier: ^4.5.0 + version: 4.5.0 '@azure/static-web-apps-cli': specifier: ^1.1.8 version: 1.1.8 @@ -455,8 +455,9 @@ packages: resolution: {integrity: sha512-AfalUQ1ZppaKuxPPMsFEUdX6GZPB3d9paR9d/TTL7Ow2De8cJaC7ibi7kWVlFAVPCYo31OcnGymc0R89DX8Oaw==} engines: {node: '>=18.0.0'} - '@azure/functions@3.5.1': - resolution: {integrity: sha512-6UltvJiuVpvHSwLcK/Zc6NfUwlkDLOFFx97BHCJzlWNsfiWwzwmTsxJXg4kE/LemKTHxPpfoPE+kOJ8hAdiKFQ==} + '@azure/functions@4.5.0': + resolution: {integrity: sha512-WNCiOHMQEZpezxgThD3o2McKEjUEljtQBvdw4X4oE5714eTw76h33kIj0660ZJGEnxYSx4dx18oAbg5kLMs9iQ==} + engines: {node: '>=18.0'} '@azure/identity@3.4.2': resolution: {integrity: sha512-0q5DL4uyR0EZ4RXQKD8MadGH6zTIcloUoS/RVbCpNpej4pwte0xpqYxk8K97Py2RiuUvI7F4GXpoT4046VfufA==} @@ -3783,10 +3784,6 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -6497,11 +6494,11 @@ snapshots: '@azure/abort-controller': 2.1.2 tslib: 2.6.2 - '@azure/functions@3.5.1': + '@azure/functions@4.5.0': dependencies: - iconv-lite: 0.6.3 + cookie: 0.6.0 long: 4.0.0 - uuid: 8.3.2 + undici: 6.18.1 '@azure/identity@3.4.2': dependencies: @@ -10515,10 +10512,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - ieee754@1.2.1: {} ignore@5.3.1: {} diff --git a/src/presets/azure/legacy/preset.ts b/src/presets/azure/legacy/preset.ts new file mode 100644 index 0000000000..6d7d7aff72 --- /dev/null +++ b/src/presets/azure/legacy/preset.ts @@ -0,0 +1,25 @@ +import { defineNitroPreset } from "nitropack"; +import type { Nitro } from "nitropack"; +import { writeFunctionsRoutes } from "./utils"; + +const azureFunctions = defineNitroPreset( + { + serveStatic: true, + entry: "./runtime/azure-functions", + commands: { + deploy: + "az functionapp deployment source config-zip -g -n --src {{ output.dir }}/deploy.zip", + }, + hooks: { + async compiled(ctx: Nitro) { + await writeFunctionsRoutes(ctx); + }, + }, + }, + { + name: "azure-functions" as const, + url: import.meta.url, + } +); + +export default [azureFunctions] as const; diff --git a/src/presets/azure/legacy/runtime/azure-functions.ts b/src/presets/azure/legacy/runtime/azure-functions.ts new file mode 100644 index 0000000000..17beec9c24 --- /dev/null +++ b/src/presets/azure/legacy/runtime/azure-functions.ts @@ -0,0 +1,26 @@ +import "#internal/nitro/virtual/polyfill"; +import { nitroApp } from "#internal/nitro/app"; +import { getAzureParsedCookiesFromHeaders } from "#internal/nitro/utils.azure"; +import { normalizeLambdaOutgoingHeaders } from "#internal/nitro/utils.lambda"; + +import type { HttpRequest, HttpResponse } from "@azure/functions"; + +export async function handle(context: { res: HttpResponse }, req: HttpRequest) { + const url = "/" + (req.params.url || ""); + + const { body, status, statusText, headers } = await nitroApp.localCall({ + url, + headers: req.headers, + method: req.method || undefined, + // https://github.com/Azure/azure-functions-host/issues/293 + body: req.rawBody, + }); + + context.res = { + status, + // cookies https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node?tabs=typescript%2Cwindows%2Cazure-cli&pivots=nodejs-model-v4#http-response + cookies: getAzureParsedCookiesFromHeaders(headers), + headers: normalizeLambdaOutgoingHeaders(headers, true), + body: body ? body.toString() : statusText, + }; +} diff --git a/src/presets/azure/legacy/utils.ts b/src/presets/azure/legacy/utils.ts new file mode 100644 index 0000000000..05954c430a --- /dev/null +++ b/src/presets/azure/legacy/utils.ts @@ -0,0 +1,59 @@ +import { createWriteStream } from "node:fs"; +import archiver from "archiver"; +import { join, resolve } from "pathe"; +import { writeFile } from "../../_utils"; +import type { Nitro } from "nitropack"; + +export async function writeFunctionsRoutes(nitro: Nitro) { + const host = { + version: "2.0", + extensions: { http: { routePrefix: "" } }, + }; + + 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) + ); + await writeFile( + resolve(nitro.options.output.dir, "host.json"), + JSON.stringify(host) + ); + await _zipDirectory( + nitro.options.output.dir, + join(nitro.options.output.dir, "deploy.zip") + ); +} + +function _zipDirectory(dir: string, outfile: string): Promise { + const archive = archiver("zip", { zlib: { level: 9 } }); + const stream = createWriteStream(outfile); + + return new Promise((resolve, reject) => { + archive + .directory(dir, false) + .on("error", (err: Error) => reject(err)) + .pipe(stream); + + stream.on("close", () => resolve(undefined)); + archive.finalize(); + }); +} diff --git a/src/presets/azure/preset.ts b/src/presets/azure/preset.ts index e7b3892b9d..bc26ba9f2a 100644 --- a/src/presets/azure/preset.ts +++ b/src/presets/azure/preset.ts @@ -2,6 +2,8 @@ import { defineNitroPreset } from "nitropack"; import type { Nitro } from "nitropack"; import { writeFunctionsRoutes, writeSWARoutes } from "./utils"; +import azureLegacyPresets from "./legacy/preset"; + export type { AzureOptions as PresetOptions } from "./types"; const azure = defineNitroPreset( @@ -45,8 +47,11 @@ const azureFunctions = defineNitroPreset( }, { name: "azure-functions" as const, + compatibility: { + date: "2024-05-29", + }, url: import.meta.url, } ); -export default [azure, azureFunctions] as const; +export default [...azureLegacyPresets, azure, azureFunctions] as const; diff --git a/src/presets/azure/runtime/azure-functions.ts b/src/presets/azure/runtime/azure-functions.ts index 17beec9c24..249e8bcd9b 100644 --- a/src/presets/azure/runtime/azure-functions.ts +++ b/src/presets/azure/runtime/azure-functions.ts @@ -1,26 +1,42 @@ import "#internal/nitro/virtual/polyfill"; import { nitroApp } from "#internal/nitro/app"; -import { getAzureParsedCookiesFromHeaders } from "#internal/nitro/utils.azure"; -import { normalizeLambdaOutgoingHeaders } from "#internal/nitro/utils.lambda"; -import type { HttpRequest, HttpResponse } from "@azure/functions"; +import { normalizeLambdaOutgoingHeaders } from "#internal/nitro/utils.lambda"; +import { normalizeAzureFunctionIncomingHeaders } from "#internal/nitro/utils.azure"; -export async function handle(context: { res: HttpResponse }, req: HttpRequest) { - const url = "/" + (req.params.url || ""); +import { app } from "@azure/functions"; - const { body, status, statusText, headers } = await nitroApp.localCall({ - url, - headers: req.headers, - method: req.method || undefined, - // https://github.com/Azure/azure-functions-host/issues/293 - body: req.rawBody, - }); +import type { HttpRequest } from "@azure/functions"; - context.res = { - status, - // cookies https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node?tabs=typescript%2Cwindows%2Cazure-cli&pivots=nodejs-model-v4#http-response - cookies: getAzureParsedCookiesFromHeaders(headers), - headers: normalizeLambdaOutgoingHeaders(headers, true), - body: body ? body.toString() : statusText, - }; +function getPathFromUrl(urlString: string): string { + try { + const url = new URL(urlString); + return url.pathname; + } catch { + return "/"; + } } + +app.http("nitro-server", { + route: "{*path}", + methods: ["GET", "POST", "PUT", "PATCH", "HEAD", "OPTIONS", "DELETE"], + handler: async (request: HttpRequest) => { + const url = getPathFromUrl(request.url); + + const { body, status, headers } = await nitroApp.localCall({ + url, + headers: normalizeAzureFunctionIncomingHeaders(request) as Record< + string, + string | string[] + >, + method: request.method || undefined, + body: request.body, + }); + + return { + body: body as any, + headers: normalizeLambdaOutgoingHeaders(headers), + status, + }; + }, +}); diff --git a/src/presets/azure/utils.ts b/src/presets/azure/utils.ts index c314dbd3a1..a6f46ab8e6 100644 --- a/src/presets/azure/utils.ts +++ b/src/presets/azure/utils.ts @@ -11,37 +11,41 @@ export async function writeFunctionsRoutes(nitro: Nitro) { extensions: { http: { routePrefix: "" } }, }; - 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", - }, - ], + const packageJson = { + name: "nitro-server", + type: "module", + main: "server/*.mjs", + }; + + // Allows the output folder to be runned locally with azure functions runtime + const localSettings = { + IsEncrypted: false, + Values: { + FUNCTIONS_WORKER_RUNTIME: "node", + AzureWebJobsFeatureFlags: "EnableWorkerIndexing", + AzureWebJobsStorage: "", + }, }; - await writeFile( - resolve(nitro.options.output.serverDir, "function.json"), - JSON.stringify(functionDefinition) - ); await writeFile( resolve(nitro.options.output.dir, "host.json"), JSON.stringify(host) ); + + await writeFile( + resolve(nitro.options.output.dir, "package.json"), + JSON.stringify(packageJson) + ); + await _zipDirectory( nitro.options.output.dir, join(nitro.options.output.dir, "deploy.zip") ); + + await writeFile( + resolve(nitro.options.output.dir, "local.settings.json"), + JSON.stringify(localSettings) + ); } export async function writeSWARoutes(nitro: Nitro) { diff --git a/src/runtime/utils.azure.ts b/src/runtime/utils.azure.ts index 3bd770c9b4..eed4bc496a 100644 --- a/src/runtime/utils.azure.ts +++ b/src/runtime/utils.azure.ts @@ -1,4 +1,4 @@ -import type { Cookie } from "@azure/functions"; +import type { Cookie, HttpRequest } from "@azure/functions"; import { parse } from "cookie-es"; import { splitCookiesString } from "h3"; @@ -39,6 +39,17 @@ export function getAzureParsedCookiesFromHeaders( return azureCookies; } +export function normalizeAzureFunctionIncomingHeaders( + request: HttpRequest +): Record { + return Object.fromEntries( + Object.entries(request.headers || {}).map(([key, value]) => [ + key.toLowerCase(), + value, + ]) + ); +} + function parseNumberOrDate(expires: string) { const expiresAsNumber = parseNumber(expires); if (expiresAsNumber !== undefined) {