Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(azure-functions): support azure functions v4 #2479

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
8 changes: 8 additions & 0 deletions docs/2.deploy/20.providers/azure.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ az functionapp deployment source config-zip -g <resource-group> -n <app-name> --
cd dist && func azure functionapp publish --javascript <app-name>
```

### 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).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 8 additions & 15 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions src/presets/azure/legacy/preset.ts
Original file line number Diff line number Diff line change
@@ -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 <resource-group> -n <app-name> --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;
26 changes: 26 additions & 0 deletions src/presets/azure/legacy/runtime/azure-functions.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
59 changes: 59 additions & 0 deletions src/presets/azure/legacy/utils.ts
Original file line number Diff line number Diff line change
@@ -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<undefined> {
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();
});
}
7 changes: 6 additions & 1 deletion src/presets/azure/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
54 changes: 35 additions & 19 deletions src/presets/azure/runtime/azure-functions.ts
Original file line number Diff line number Diff line change
@@ -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,
};
},
});
46 changes: 25 additions & 21 deletions src/presets/azure/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
13 changes: 12 additions & 1 deletion src/runtime/utils.azure.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -39,6 +39,17 @@ export function getAzureParsedCookiesFromHeaders(
return azureCookies;
}

export function normalizeAzureFunctionIncomingHeaders(
request: HttpRequest
): Record<string, string | string[] | undefined> {
return Object.fromEntries(
Object.entries(request.headers || {}).map(([key, value]) => [
key.toLowerCase(),
value,
])
);
}

function parseNumberOrDate(expires: string) {
const expiresAsNumber = parseNumber(expires);
if (expiresAsNumber !== undefined) {
Expand Down