Repository: geoffrich/svelte-adapter-azure-swa (js)

Issue:
Support v4 of azure functions
Right now the adaptor seems to generate a v3 function sk_render but in my project I'm using v4 functions.

Pull request:
feat: switch to Azure Functions v4 
This switches to the new v4 runtime of Azure functions in Azure Static Webapps

* remove function.json - it is not needed anymore
* rename function path to "sk_render" since v4 does not allow the path to start with "__"
* add @azure/functions to external esbuild options (if not already there) -  it's needed by the new runtime
    
See also: https://techcommunity.microsoft.com/t5/apps-on-azure-blog/azure-functions-version-4-of-the-node-js-programming-model-is-in/ba-p/3773541

closes #159


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fafb334..b158bc5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
 # Changelog
 
+## [Unreleased]
+
+### Features
+
+* **Breaking:** switch to Azure Functions v4:
+  * the function path has changed from `/api/__render` to `/api/sk_render` (v4 does not allow routes starting with underscores)
+  * see also [Migrate to version 4 of the Node.js programming model for Azure Functions](https://learn.microsoft.com/en-us/azure/azure-functions/functions-node-upgrade-v4) from the Azure docs
+
+
+
 ### [0.20.1](https://www.github.com/geoffrich/svelte-adapter-azure-swa/compare/v0.20.0...v0.20.1) (2024-07-13)
 
 
diff --git a/README.md b/README.md
index 9bc9986..1c4b728 100644
--- a/README.md
+++ b/README.md
@@ -119,7 +119,7 @@ custom/
         └── index.js
 ```
 
-Also note that the adapter reserves the folder prefix `sk_render` and API route prefix `__render` for Azure functions generated by the adapter. So, if you use a custom API directory, you cannot have any other folder starting with `sk_render` or functions available at the `__render` route, since these will conflict with the adapter's Azure functions.
+Also note that the adapter reserves the folder prefix `sk_render` and API route prefix `sk_render` for Azure functions generated by the adapter. So, if you use a custom API directory, you cannot have any other folder starting with `sk_render` or functions available at the `sk_render` route, since these will conflict with the adapter's Azure functions.
 
 ### staticDir
 
@@ -240,6 +240,10 @@ This is currently only available when running in production on SWA. In addition,
 
 All server requests to your SvelteKit app are handled by an Azure function. This property contains that Azure function's [request context](https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node#context-object).
 
+### `user`
+
+The `user` property of the Azure function's [HTTP request](https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node?tabs=javascript%2Cwindows%2Cazure-cli&pivots=nodejs-model-v4#http-request).
+
 ## Monorepo support
 
 If you're deploying your app from a monorepo, here's what you need to know.
diff --git a/demo/func/HelloWorld/function.json b/demo/func/HelloWorld/function.json
deleted file mode 100644
index 1c1f040..0000000
--- a/demo/func/HelloWorld/function.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-	"bindings": [
-		{
-			"authLevel": "anonymous",
-			"type": "httpTrigger",
-			"direction": "in",
-			"name": "req",
-			"methods": ["get", "post"]
-		},
-		{
-			"type": "http",
-			"direction": "out",
-			"name": "res"
-		}
-	]
-}
diff --git a/demo/func/HelloWorld/index.js b/demo/func/HelloWorld/index.js
index bd41adb..4189cc9 100644
--- a/demo/func/HelloWorld/index.js
+++ b/demo/func/HelloWorld/index.js
@@ -1,13 +1,22 @@
-module.exports = async function (context, req) {
-	context.log('JavaScript HTTP trigger function processed a request.');
-
-	const name = req.query.name || (req.body && req.body.name);
-	const responseMessage = name
-		? 'Hello, ' + name + '. This HTTP triggered function executed successfully.'
-		: 'This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.';
-
-	context.res = {
-		// status: 200, /* Defaults to 200 */
-		body: responseMessage
-	};
-};
+const { app } = require('@azure/functions');
+
+app.http('httpTrigger1', {
+	methods: ['GET', 'POST'],
+	handler: async (req, context) => {
+		context.log('JavaScript HTTP trigger function processed a request.');
+
+		let name;
+		if (req.query.has('name')) {
+			name = req.query.get('name')
+		} else {
+			let body = await req.json();
+			name = body.name;
+		}
+
+		const responseMessage = name
+			? 'Hello, ' + name + '. This HTTP triggered function executed successfully.'
+			: 'This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.';
+
+		return { body: responseMessage };
+	}
+});
diff --git a/demo/func/host.json b/demo/func/host.json
index 14992fc..dd16270 100644
--- a/demo/func/host.json
+++ b/demo/func/host.json
@@ -2,6 +2,6 @@
 	"version": "2.0",
 	"extensionBundle": {
 		"id": "Microsoft.Azure.Functions.ExtensionBundle",
-		"version": "[2.*, 3.0.0)"
+		"version": "[4.0.0, 5.0.0)"
 	}
 }
diff --git a/files/api/host.json b/files/api/host.json
index 14992fc..dd16270 100644
--- a/files/api/host.json
+++ b/files/api/host.json
@@ -2,6 +2,6 @@
 	"version": "2.0",
 	"extensionBundle": {
 		"id": "Microsoft.Azure.Functions.ExtensionBundle",
-		"version": "[2.*, 3.0.0)"
+		"version": "[4.0.0, 5.0.0)"
 	}
 }
diff --git a/files/api/package.json b/files/api/package.json
index 0967ef4..88851a9 100644
--- a/files/api/package.json
+++ b/files/api/package.json
@@ -1,1 +1,6 @@
-{}
+{
+	"main": "sk_render/index.js",
+	"dependencies": {
+		"@azure/functions": "^4"
+	}
+}
diff --git a/files/entry.js b/files/entry.js
index 2ae126e..37b11ee 100644
--- a/files/entry.js
+++ b/files/entry.js
@@ -6,6 +6,7 @@ import {
 	getClientPrincipalFromHeaders,
 	splitCookiesFromHeaders
 } from './headers';
+import { app } from '@azure/functions';
 
 // replaced at build time
 // @ts-expect-error
@@ -17,99 +18,103 @@ const server = new Server(manifest);
 const initialized = server.init({ env: process.env });
 
 /**
- * @typedef {import('@azure/functions').AzureFunction} AzureFunction
- * @typedef {import('@azure/functions').Context} Context
+ * @typedef {import('@azure/functions').InvocationContext} InvocationContext
  * @typedef {import('@azure/functions').HttpRequest} HttpRequest
+ * @typedef {import('@azure/functions').HttpResponse} HttpResponse
  */
 
-/**
- * @param {Context} context
- */
-export async function index(context) {
-	const request = toRequest(context);
-
-	if (debug) {
-		context.log(
-			'Starting request',
-			context?.req?.method,
-			context?.req?.headers?.['x-ms-original-url']
-		);
-		context.log(`Original request: ${JSON.stringify(context)}`);
-		context.log(`Request: ${JSON.stringify(request)}`);
-	}
-
-	const ipAddress = getClientIPFromHeaders(request.headers);
-	const clientPrincipal = getClientPrincipalFromHeaders(request.headers);
-
-	await initialized;
-	const rendered = await server.respond(request, {
-		getClientAddress() {
-			return ipAddress;
-		},
-		platform: {
-			clientPrincipal,
-			context
+app.http('sk_render', {
+	methods: ['HEAD', 'GET', 'POST', 'DELETE', 'PUT', 'OPTIONS'],
+	/**
+	 *
+	 * @param {HttpRequest} httpRequest
+	 * @param {InvocationContext} context
+	 */
+	handler: async (httpRequest, context) => {
+		if (debug) {
+			context.log(
+				'Starting request',
+				httpRequest.method,
+				httpRequest.headers.get('x-ms-original-url')
+			);
+			context.log(`Request: ${JSON.stringify(httpRequest)}`);
 		}
-	});
 
-	const response = await toResponse(rendered);
+		const request = toRequest(httpRequest);
+
+		const ipAddress = getClientIPFromHeaders(request.headers);
+		const clientPrincipal = getClientPrincipalFromHeaders(request.headers);
+
+		await initialized;
+		const rendered = await server.respond(request, {
+			getClientAddress() {
+				return ipAddress;
+			},
+			platform: {
+				user: httpRequest.user,
+				clientPrincipal,
+				context
+			}
+		});
+
+		if (debug) {
+			context.log(`SK headers: ${JSON.stringify(Object.fromEntries(rendered.headers.entries()))}`);
+			context.log(`Response: ${JSON.stringify(rendered)}`);
+		}
 
-	if (debug) {
-		context.log(`SK headers: ${JSON.stringify(Object.fromEntries(rendered.headers.entries()))}`);
-		context.log(`Response: ${JSON.stringify(response)}`);
+		return toResponse(rendered);
 	}
-
-	context.res = response;
-}
+});
 
 /**
- * @param {Context} context
+ * @param {HttpRequest} httpRequest
  * @returns {Request}
- * */
-function toRequest(context) {
-	const { method, headers, rawBody, body } = context.req;
-	// because we proxy all requests to the render function, the original URL in the request is /api/__render
+ */
+function toRequest(httpRequest) {
+	// because we proxy all requests to the render function, the original URL in the request is /api/sk_render
 	// this header contains the URL the user requested
-	const originalUrl = headers['x-ms-original-url'];
+	const originalUrl = httpRequest.headers.get('x-ms-original-url');
 
 	// SWA strips content-type headers from empty POST requests, but SK form actions require the header
 	// https://github.com/geoffrich/svelte-adapter-azure-swa/issues/178
-	if (method === 'POST' && !body && !headers['content-type']) {
-		headers['content-type'] = 'application/x-www-form-urlencoded';
+	if (
+		httpRequest.method === 'POST' &&
+		!httpRequest.body &&
+		!httpRequest.headers.get('content-type')
+	) {
+		httpRequest.headers.set('content-type', 'application/x-www-form-urlencoded');
 	}
 
-	/** @type {RequestInit} */
-	const init = {
-		method,
-		headers: new Headers(headers)
-	};
-
-	if (method !== 'GET' && method !== 'HEAD') {
-		init.body = Buffer.isBuffer(body)
-			? body
-			: typeof rawBody === 'string'
-				? Buffer.from(rawBody, 'utf-8')
-				: rawBody;
-	}
+	/** @type {Record<string, string>} */
+	const headers = {};
+	httpRequest.headers.forEach((value, key) => {
+		if (key !== 'x-ms-original-url') {
+			headers[key] = value;
+		}
+	});
 
-	return new Request(originalUrl, init);
+	return new Request(originalUrl, {
+		method: httpRequest.method,
+		headers: new Headers(headers),
+		// @ts-ignore
+		body: httpRequest.body,
+		duplex: 'half'
+	});
 }
 
 /**
  * @param {Response} rendered
- * @returns {Promise<Record<string, any>>}
+ * @returns {Promise<HttpResponse>}
  */
 async function toResponse(rendered) {
-	const { status } = rendered;
-	const resBody = new Uint8Array(await rendered.arrayBuffer());
-
 	const { headers, cookies } = splitCookiesFromHeaders(rendered.headers);
 
 	return {
-		status,
-		body: resBody,
+		status: rendered.status,
+		// @ts-ignore
+		body: rendered.body,
 		headers,
 		cookies,
-		isRaw: true
+		enableContentNegotiation: false
 	};
 }
diff --git a/files/headers.js b/files/headers.js
index b606ab6..24822ad 100644
--- a/files/headers.js
+++ b/files/headers.js
@@ -1,30 +1,35 @@
 import * as set_cookie_parser from 'set-cookie-parser';
 
+/**
+ * @typedef {import('@azure/functions').Cookie} Cookie
+ */
+
 /**
  * Splits 'set-cookie' headers into individual cookies
  * @param {Headers} headers
  * @returns {{
- *   headers: Record<string, string>,
- *   cookies: set_cookie_parser.Cookie[]
+ *   headers: Headers,
+ *   cookies: Cookie[]
  * }}
  */
 export function splitCookiesFromHeaders(headers) {
 	/** @type {Record<string, string>} */
 	const resHeaders = {};
 
-	/** @type {set_cookie_parser.Cookie[]} */
+	/** @type {Cookie[]} */
 	const resCookies = [];
 
 	headers.forEach((value, key) => {
 		if (key === 'set-cookie') {
 			const cookieStrings = set_cookie_parser.splitCookiesString(value);
+			// @ts-expect-error - one cookie type has a stricter sameSite type
 			resCookies.push(...set_cookie_parser.parse(cookieStrings));
 		} else {
 			resHeaders[key] = value;
 		}
 	});
 
-	return { headers: resHeaders, cookies: resCookies };
+	return { headers: new Headers(resHeaders), cookies: resCookies };
 }
 
 /**
diff --git a/index.d.ts b/index.d.ts
index dd304be..7f6c30f 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1,6 +1,6 @@
 import { Adapter } from '@sveltejs/kit';
 import { ClientPrincipal, CustomStaticWebAppConfig } from './types/swa';
-import { Context } from '@azure/functions';
+import { HttpRequestUser, InvocationContext } from '@azure/functions';
 import esbuild from 'esbuild';
 
 export * from './types/swa';
@@ -37,8 +37,11 @@ declare global {
 			 *
 			 * @see The {@link https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node#context-object Azure function documentation}
 			 */
+			context: InvocationContext;
+
+			user: HttpRequestUser;
+
 			clientPrincipal?: ClientPrincipal;
-			context: Context;
 		}
 	}
 }
diff --git a/index.js b/index.js
index f59a34e..279deaf 100644
--- a/index.js
+++ b/index.js
@@ -7,26 +7,7 @@ import esbuild from 'esbuild';
  * @typedef {import('esbuild').BuildOptions} BuildOptions
  */
 
-const ssrFunctionRoute = '/api/__render';
-
-const functionJson = `
-{
-	"bindings": [
-		{
-			"authLevel": "anonymous",
-			"type": "httpTrigger",
-			"direction": "in",
-			"name": "req",
-			"route": "__render"
-		},
-		{
-			"type": "http",
-			"direction": "out",
-			"name": "res"
-		}
-	]
-}
-`;
+const ssrFunctionRoute = '/api/sk_render';
 
 /**
  * Validate the static web app configuration does not override the minimum config for the adapter to work correctly.
@@ -123,6 +104,11 @@ If you want to suppress this error, set allowReservedSwaRoutes to true in your a
 				})};\n`
 			);
 
+			// add @azure/functions to esbuildOptions.external if not already set - this is needed by the Azure Functiions v4 runtime
+			if (!esbuildOptions.external?.includes('@azure/functions')) {
+				esbuildOptions.external = [...(esbuildOptions.external || []), '@azure/functions'];
+			}
+
 			/** @type {BuildOptions} */
 			const default_options = {
 				entryPoints: [entry],
@@ -137,7 +123,6 @@ If you want to suppress this error, set allowReservedSwaRoutes to true in your a
 			};
 
 			await esbuild.build(default_options);
-			writeFileSync(join(functionDir, 'function.json'), functionJson);
 
 			builder.log.minor('Copying assets...');
 			builder.writeClient(staticDir);
diff --git a/package-lock.json b/package-lock.json
index a1a5582..7b1cf46 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,14 +6,14 @@
 	"packages": {
 		"": {
 			"name": "svelte-adapter-azure-swa",
-			"version": "0.19.1",
+			"version": "0.20.1",
 			"license": "MIT",
 			"dependencies": {
 				"esbuild": "^0.19.9",
 				"set-cookie-parser": "^2.6.0"
 			},
 			"devDependencies": {
-				"@azure/functions": "^1.2.3",
+				"@azure/functions": "^4",
 				"@sveltejs/kit": "^2.0.4",
 				"@types/node": "^18.19.3",
 				"@types/set-cookie-parser": "^2.4.7",
@@ -40,10 +40,18 @@
 			}
 		},
 		"node_modules/@azure/functions": {
-			"version": "1.2.3",
-			"resolved": "https://registry.npmjs.org/@azure/functions/-/functions-1.2.3.tgz",
-			"integrity": "sha512-dZITbYPNg6ay6ngcCOjRUh1wDhlFITS0zIkqplyH5KfKEAVPooaoaye5mUFnR+WP9WdGRjlNXyl/y2tgWKHcRg==",
-			"dev": true
+			"version": "4.5.0",
+			"resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.5.0.tgz",
+			"integrity": "sha512-WNCiOHMQEZpezxgThD3o2McKEjUEljtQBvdw4X4oE5714eTw76h33kIj0660ZJGEnxYSx4dx18oAbg5kLMs9iQ==",
+			"dev": true,
+			"dependencies": {
+				"cookie": "^0.6.0",
+				"long": "^4.0.0",
+				"undici": "^5.13.0"
+			},
+			"engines": {
+				"node": ">=18.0"
+			}
 		},
 		"node_modules/@esbuild/aix-ppc64": {
 			"version": "0.19.10",
@@ -390,6 +398,15 @@
 				"node": ">=12"
 			}
 		},
+		"node_modules/@fastify/busboy": {
+			"version": "2.1.1",
+			"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
+			"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
+			"dev": true,
+			"engines": {
+				"node": ">=14"
+			}
+		},
 		"node_modules/@jest/schemas": {
 			"version": "29.6.3",
 			"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
@@ -1220,6 +1237,12 @@
 			"dev": true,
 			"peer": true
 		},
+		"node_modules/long": {
+			"version": "4.0.0",
+			"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
+			"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
+			"dev": true
+		},
 		"node_modules/loupe": {
 			"version": "2.3.7",
 			"resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
@@ -1746,6 +1769,18 @@
 			"integrity": "sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==",
 			"dev": true
 		},
+		"node_modules/undici": {
+			"version": "5.28.4",
+			"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
+			"integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==",
+			"dev": true,
+			"dependencies": {
+				"@fastify/busboy": "^2.0.0"
+			},
+			"engines": {
+				"node": ">=14.0"
+			}
+		},
 		"node_modules/undici-types": {
 			"version": "5.26.5",
 			"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
diff --git a/package.json b/package.json
index 901ddbc..726f1e6 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
 		"@sveltejs/kit": "^2.0.0"
 	},
 	"devDependencies": {
-		"@azure/functions": "^1.2.3",
+		"@azure/functions": "^4",
 		"@sveltejs/kit": "^2.0.4",
 		"@types/node": "^18.19.3",
 		"@types/set-cookie-parser": "^2.4.7",


Test patch:
diff --git a/test/headers.test.js b/test/headers.test.js
index 37145bc..b807e5c 100644
--- a/test/headers.test.js
+++ b/test/headers.test.js
@@ -18,10 +18,7 @@ describe('header processing', () => {
 
 		expect(cookies).toEqual({
 			cookies: [],
-			headers: {
-				'content-type': 'application/json',
-				location: '/'
-			}
+			headers
 		});
 	});
 
@@ -40,7 +37,7 @@ describe('header processing', () => {
 		const cookies = splitCookiesFromHeaders(headers);
 
 		expect(cookies).toStrictEqual({
-			headers: {},
+			headers: new Headers(),
 			cookies: [
 				{
 					expires: new Date('1994-11-06T08:49:37.000Z'),
diff --git a/test/index.test.js b/test/index.test.js
index 2155895..fd35ded 100644
--- a/test/index.test.js
+++ b/test/index.test.js
@@ -2,6 +2,7 @@ import { expect, describe, test, vi } from 'vitest';
 import azureAdapter, { generateConfig } from '../index';
 import { writeFileSync, existsSync } from 'fs';
 import { jsonMatching, toMatchJSON } from './json';
+import esbuild from 'esbuild';
 
 expect.extend({ jsonMatching, toMatchJSON });
 
@@ -21,7 +22,7 @@ describe('generateConfig', () => {
 		const result = generateConfig({}, 'appDir');
 		expect(result).toStrictEqual({
 			navigationFallback: {
-				rewrite: '/api/__render'
+				rewrite: '/api/sk_render'
 			},
 			platform: {
 				apiRuntime: 'node:18'
@@ -29,7 +30,7 @@ describe('generateConfig', () => {
 			routes: expect.arrayContaining([
 				{
 					methods: ['POST', 'PUT', 'DELETE'],
-					rewrite: '/api/__render',
+					rewrite: '/api/sk_render',
 					route: '*'
 				},
 				{
@@ -77,10 +78,10 @@ describe('adapt', () => {
 		const adapter = azureAdapter({ apiDir: 'custom/api' });
 		const builder = getMockBuilder();
 		await adapter.adapt(builder);
-		expect(writeFileSync).toBeCalledWith(
-			'custom/api/sk_render/function.json',
-			expect.stringContaining('__render')
-		);
+		expect(esbuild.build).toBeCalledWith(expect.objectContaining({
+			outfile: 'custom/api/sk_render/index.js',
+		}));
+
 		// we don't copy the required function files to a custom API directory
 		expect(builder.copy).not.toBeCalledWith(expect.stringContaining('api'), 'custom/api');
 	});
@@ -116,11 +117,11 @@ describe('adapt', () => {
 					routes: expect.arrayContaining([
 						{
 							route: '/index.html',
-							rewrite: '/api/__render'
+							rewrite: '/api/sk_render'
 						},
 						{
 							route: '/',
-							rewrite: '/api/__render'
+							rewrite: '/api/sk_render'
 						}
 					])
 				})