feat: Azure Functions deployment target (v4, Node 22) (#330)

* feat: add Azure Functions deployment target (v4, Node 22)

- server/entry-azure.ts: Azure Functions v4 handler wrapping the Hono
  app via app.http(); uses createLibsqlPlatform for Turso and serves
  the SPA from ./dist via @hono/node-server/serve-static
- server/azure-host.json: runtime manifest (extensionBundle v4)
- deploy/azure-functions/main.bicep: idempotent Bicep template
  provisioning Storage Account, Consumption plan and Function App;
  BETTER_AUTH_SECRET handled separately by the workflow
- .github/workflows/deploy-azure.yml: 8-step workflow (secret check,
  checkout, Node setup, az login, Bicep deploy, build, db:migrate,
  func publish) with BETTER_AUTH_SECRET generate-if-missing logic
- package.json: build:azure script + @azure/functions dependency
- docs/deploy/azure-functions.md: setup guide covering SP JSON format,
  required secrets, and local emulation with func start

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* fix: address review issues in Azure Functions deploy

- Move BETTER_AUTH_SECRET and APP_URL setup to before func publish
  (bootstrap.ts throws on missing secret; any request between publish
  and the old secret-set step would have returned 500)
- Remove placeholder appUrl Bicep param; workflow sets APP_URL and
  BETTER_AUTH_URL via appsettings after Bicep, before publish
- Fix HttpRequest→Request body handling: construct a proper Web API
  Request with body cast and duplex option instead of double-casting
  HttpRequest, ensuring POST/PUT/PATCH body-reading routes work
- Add push: branches: [master] trigger + upstream guard to match other
  deploy workflow conventions; document the auto-deploy behaviour
- Update docs/deploy/azure-functions.md to reflect the push trigger

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* ci: re-trigger CI for review fixes

---------

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
Jasper Van
2026-04-22 02:08:54 -04:00
committed by GitHub
parent 8b72a7dba9
commit d33800f23e
7 changed files with 539 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
name: Deploy to Azure Functions
on:
push:
branches: [master]
workflow_dispatch:
inputs:
resource_group:
description: 'Azure Resource Group name (created if absent)'
required: true
default: 'zpan-rg'
location:
description: 'Azure region (e.g. eastus)'
required: true
default: 'eastus'
version:
description: 'Release tag to deploy (e.g. v2.5.0). Leave empty for latest.'
required: false
# Prevent overlapping deployments.
concurrency:
group: deploy-azure
cancel-in-progress: false
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
# Only run on forks that have configured Azure credentials.
# The upstream repo uses Cloudflare Workers Builds; Azure is for self-hosters.
if: github.repository != 'saltbo/zpan'
steps:
# ------------------------------------------------------------------
# Step 1 — Verify all required secrets are present before doing work.
# ------------------------------------------------------------------
- name: Check required secrets
env:
HAS_AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS != '' }}
HAS_TURSO_URL: ${{ secrets.TURSO_DATABASE_URL != '' }}
HAS_TURSO_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN != '' }}
run: |
MISSING=()
[ "$HAS_AZURE_CREDENTIALS" != "true" ] && MISSING+=("AZURE_CREDENTIALS")
[ "$HAS_TURSO_URL" != "true" ] && MISSING+=("TURSO_DATABASE_URL")
[ "$HAS_TURSO_TOKEN" != "true" ] && MISSING+=("TURSO_AUTH_TOKEN")
if [ ${#MISSING[@]} -gt 0 ]; then
echo "::error::Missing required secrets: ${MISSING[*]}"
echo "Go to Settings → Secrets and variables → Actions and add the missing secrets."
exit 1
fi
# ------------------------------------------------------------------
# Step 2 — Resolve release tag and check out that version.
# ------------------------------------------------------------------
- name: Resolve release tag
id: release
env:
GH_TOKEN: ${{ github.token }}
INPUT_VERSION: ${{ inputs.version }}
run: |
if [ -n "$INPUT_VERSION" ]; then
TAG="$INPUT_VERSION"
else
TAG=$(gh api repos/saltbo/zpan/releases/latest --jq '.tag_name')
fi
if [ -z "$TAG" ]; then
echo "::error::No release found in saltbo/zpan"
exit 1
fi
echo "version=$TAG" >> "$GITHUB_OUTPUT"
echo "### 🚀 Deploying $TAG to Azure Functions" >> "$GITHUB_STEP_SUMMARY"
- uses: actions/checkout@v4
with:
repository: saltbo/zpan
ref: ${{ steps.release.outputs.version }}
# ------------------------------------------------------------------
# Step 3 — Node.js setup + install dependencies.
# ------------------------------------------------------------------
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
# ------------------------------------------------------------------
# Step 4 — Log in to Azure using the service-principal credentials.
# ------------------------------------------------------------------
- name: Azure login
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
# ------------------------------------------------------------------
# Step 5 — Provision infrastructure (idempotent create-or-update).
# ------------------------------------------------------------------
- name: Resolve inputs (push vs. dispatch)
id: params
run: |
echo "resource_group=${{ inputs.resource_group || 'zpan-rg' }}" >> "$GITHUB_OUTPUT"
echo "location=${{ inputs.location || 'eastus' }}" >> "$GITHUB_OUTPUT"
- name: Ensure Resource Group exists
run: |
az group create \
--name "${{ steps.params.outputs.resource_group }}" \
--location "${{ steps.params.outputs.location }}" \
--output none
- name: Deploy Bicep template
id: bicep
env:
TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}
run: |
OUTPUT=$(az deployment group create \
--resource-group "${{ steps.params.outputs.resource_group }}" \
--template-file deploy/azure-functions/main.bicep \
--parameters \
tursoDatabaseUrl="$TURSO_DATABASE_URL" \
tursoAuthToken="$TURSO_AUTH_TOKEN" \
--query "properties.outputs" \
--output json)
FUNC_NAME=$(echo "$OUTPUT" | jq -r '.functionAppName.value')
FUNC_URL=$(echo "$OUTPUT" | jq -r '.functionAppUrl.value')
echo "functionAppName=$FUNC_NAME" >> "$GITHUB_OUTPUT"
echo "functionAppUrl=$FUNC_URL" >> "$GITHUB_OUTPUT"
echo "Function App: $FUNC_NAME ($FUNC_URL)" >> "$GITHUB_STEP_SUMMARY"
# ------------------------------------------------------------------
# Step 6a — Set BETTER_AUTH_SECRET before publish.
# The Function App exists after Bicep; setting the secret now means
# the very first invocation after publish already has it configured.
# bootstrap.ts throws 'BETTER_AUTH_SECRET is required' if it is absent,
# so publishing before this step would cause 500s until the step ran.
# ------------------------------------------------------------------
- name: Set BETTER_AUTH_SECRET (generate once, never overwrite)
env:
USER_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
run: |
FUNC_NAME="${{ steps.bicep.outputs.functionAppName }}"
RG="${{ steps.params.outputs.resource_group }}"
EXISTS=$(az functionapp config appsettings list \
--name "$FUNC_NAME" \
--resource-group "$RG" \
--query "[?name=='BETTER_AUTH_SECRET'].value" \
--output tsv)
if [ -n "$USER_SECRET" ]; then
az functionapp config appsettings set \
--name "$FUNC_NAME" \
--resource-group "$RG" \
--settings "BETTER_AUTH_SECRET=$USER_SECRET" \
--output none
echo "Set BETTER_AUTH_SECRET from GitHub secret."
elif [ -z "$EXISTS" ]; then
GENERATED=$(openssl rand -base64 32)
az functionapp config appsettings set \
--name "$FUNC_NAME" \
--resource-group "$RG" \
--settings "BETTER_AUTH_SECRET=$GENERATED" \
--output none
echo "Auto-generated BETTER_AUTH_SECRET."
else
echo "BETTER_AUTH_SECRET already set — skipping."
fi
# ------------------------------------------------------------------
# Step 6b — Patch APP_URL / BETTER_AUTH_URL to the real hostname.
# Bicep sets both from the `appUrl` parameter (defaults to an empty
# placeholder when not provided). Finalise before publish so auth
# redirects are correct from the first request.
# ------------------------------------------------------------------
- name: Update APP_URL to real function app URL
run: |
FUNC_NAME="${{ steps.bicep.outputs.functionAppName }}"
FUNC_URL="${{ steps.bicep.outputs.functionAppUrl }}"
RG="${{ steps.params.outputs.resource_group }}"
az functionapp config appsettings set \
--name "$FUNC_NAME" \
--resource-group "$RG" \
--settings "APP_URL=$FUNC_URL" "BETTER_AUTH_URL=$FUNC_URL" \
--output none
echo "APP_URL set to $FUNC_URL"
# ------------------------------------------------------------------
# Step 7 — Build frontend + Azure Functions bundle.
# ------------------------------------------------------------------
- name: Build
run: npm run build:azure
# ------------------------------------------------------------------
# Step 8 — Run database migrations against Turso.
# ------------------------------------------------------------------
- name: Run database migrations
env:
TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}
run: npm run db:migrate
# ------------------------------------------------------------------
# Step 9 — Install Azure Functions Core Tools and publish.
# All required app settings (BETTER_AUTH_SECRET, APP_URL, Turso creds)
# are already in place before this step runs.
# ------------------------------------------------------------------
- name: Install Azure Functions Core Tools
run: npm install -g azure-functions-core-tools@4 --unsafe-perm true
- name: Publish to Azure Functions
working-directory: azure-functions
run: func azure functionapp publish "${{ steps.bicep.outputs.functionAppName }}" --node
- name: Deployment summary
run: |
echo "### ✅ Deployed: ${{ steps.bicep.outputs.functionAppUrl }}" >> "$GITHUB_STEP_SUMMARY"
+84
View File
@@ -0,0 +1,84 @@
@description('Application name — used as a prefix for all resources.')
param appName string = 'zpan'
@description('Azure region. Defaults to the resource group location.')
param location string = resourceGroup().location
@description('Turso (libSQL) database URL, e.g. libsql://your-db.turso.io')
param tursoDatabaseUrl string
@secure()
@description('Turso auth token.')
param tursoAuthToken string
// Unique suffix derived from the resource group so re-runs produce the same names (idempotent).
var suffix = uniqueString(resourceGroup().id)
var storageAccountName = take('${toLower(replace(appName, '-', ''))}${suffix}', 24)
var hostingPlanName = '${appName}-plan-${suffix}'
var functionAppName = '${appName}-func-${suffix}'
// ---------------------------------------------------------------------------
// Storage Account — required by the Azure Functions runtime.
// ---------------------------------------------------------------------------
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageAccountName
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {
supportsHttpsTrafficOnly: true
minimumTlsVersion: 'TLS1_2'
}
}
// ---------------------------------------------------------------------------
// Consumption plan (Y1 / Dynamic SKU).
// ---------------------------------------------------------------------------
resource hostingPlan 'Microsoft.Web/serverfarms@2023-01-01' = {
name: hostingPlanName
location: location
sku: {
name: 'Y1'
tier: 'Dynamic'
}
properties: {}
}
// ---------------------------------------------------------------------------
// Function App — Node 22, programming model v4.
// BETTER_AUTH_SECRET is intentionally absent here; it is managed by the
// deploy workflow so that it is generated once and never overwritten on
// subsequent runs.
// ---------------------------------------------------------------------------
resource functionApp 'Microsoft.Web/sites@2023-01-01' = {
name: functionAppName
location: location
kind: 'functionapp'
properties: {
serverFarmId: hostingPlan.id
httpsOnly: true
siteConfig: {
nodeVersion: '~22'
appSettings: [
{
name: 'AzureWebJobsStorage'
value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
}
{ name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' }
{ name: 'FUNCTIONS_WORKER_RUNTIME', value: 'node' }
{ name: 'WEBSITE_RUN_FROM_PACKAGE', value: '1' }
{ name: 'TURSO_DATABASE_URL', value: tursoDatabaseUrl }
{ name: 'TURSO_AUTH_TOKEN', value: tursoAuthToken }
]
}
}
}
// ---------------------------------------------------------------------------
// Outputs consumed by the deploy workflow.
// ---------------------------------------------------------------------------
@description('Name of the deployed Function App.')
output functionAppName string = functionApp.name
@description('Default HTTPS URL of the Function App.')
output functionAppUrl string = 'https://${functionApp.properties.defaultHostName}'
+144
View File
@@ -0,0 +1,144 @@
# Azure Functions Deployment
ZPan supports deployment to [Azure Functions](https://learn.microsoft.com/en-us/azure/azure-functions/) (programming model v4, Node.js 22) as an alternative to Cloudflare Workers or Docker. The function app serves both the Hono API and the React SPA from a single Consumption-plan function.
> **S3-compatible storage required** — Azure Blob Storage is _not_ adapted as an object backend. You must bring an external S3-compatible bucket (AWS S3, Cloudflare R2, MinIO, etc.) and configure it as a storage provider inside ZPan after deployment.
---
## Prerequisites
| Requirement | Notes |
|---|---|
| Azure subscription | Consumption-plan Functions are free up to 1 M invocations/month |
| Azure CLI | `az` ≥ 2.50 — [install](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) |
| Azure Functions Core Tools | v4 — installed automatically by the workflow |
| Turso account | [turso.tech](https://turso.tech) — free tier covers most self-hosted use cases |
| S3-compatible bucket | Any provider; configured inside ZPan post-deploy |
---
## 1 — Create a Turso database
```sh
turso db create zpan
turso db show zpan # note the URL (libsql://...)
turso db tokens create zpan # note the auth token
```
---
## 2 — Create an Azure service principal
The GitHub Actions workflow authenticates to Azure with a service principal whose credentials are stored as a single JSON secret (`AZURE_CREDENTIALS`).
```sh
# Replace <subscription-id> with your Azure subscription ID.
az ad sp create-for-rbac \
--name "zpan-deploy" \
--role Contributor \
--scopes /subscriptions/<subscription-id> \
--sdk-auth
```
The command outputs a JSON block. Copy the **entire JSON object** — it is the value for the `AZURE_CREDENTIALS` secret.
### Service-principal JSON format
```json
{
"clientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"clientSecret": "your-client-secret",
"subscriptionId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"tenantId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"activeDirectoryEndpointUrl": "https://login.microsoftonline.com",
"resourceManagerEndpointUrl": "https://management.azure.com/",
"activeDirectoryGraphResourceId": "https://graph.windows.net/",
"sqlManagementEndpointUrl": "https://management.core.windows.net:8443/",
"galleryEndpointUrl": "https://gallery.azure.com/",
"managementEndpointUrl": "https://management.core.windows.net/"
}
```
---
## 3 — Add GitHub repository secrets
In your fork go to **Settings → Secrets and variables → Actions** and add:
| Secret name | Value |
|---|---|
| `AZURE_CREDENTIALS` | Full JSON object from `az ad sp create-for-rbac --sdk-auth` (see above) |
| `TURSO_DATABASE_URL` | `libsql://your-db-name-orgname.turso.io` |
| `TURSO_AUTH_TOKEN` | Token from `turso db tokens create zpan` |
| `BETTER_AUTH_SECRET` | _(optional)_ Pre-generated secret — `openssl rand -base64 32`. If absent the workflow generates one automatically on first deploy. |
---
## 4 — Run the workflow
The workflow triggers automatically on every push to `master` **and** can be triggered manually via **Actions → Deploy to Azure Functions → Run workflow**.
> The workflow includes `if: github.repository != 'saltbo/zpan'` so it is a no-op in the upstream repo. It only runs in your fork.
| Manual dispatch input | Description |
|---|---|
| `resource_group` | Azure Resource Group name — created automatically if it does not exist (default: `zpan-rg`) |
| `location` | Azure region (default: `eastus`) |
| `version` | Release tag (e.g. `v2.5.0`). Leave empty to use the latest release. |
> When triggered by a push, defaults are used for `resource_group` (`zpan-rg`) and `location` (`eastus`).
### What the workflow does
1. **Check secrets** — fails fast if any required secret is missing.
2. **Resolve release tag** — pins to a specific ZPan release.
3. **Set up Node 22** and install dependencies.
4. **Azure login** — uses the `AZURE_CREDENTIALS` service principal.
5. **Provision infrastructure** via `deploy/azure-functions/main.bicep`:
- Resource Group (idempotent `az group create`)
- Storage Account (required by the Functions runtime)
- Consumption plan (Y1 / Dynamic SKU)
- Function App (Node 22, runtime v4)
6. **Build**`npm run build:azure` produces the `azure-functions/` publish directory.
7. **Migrate**`npm run db:migrate` applies Drizzle migrations to Turso.
8. **Publish**`func azure functionapp publish <name>` uploads the bundle.
9. **Set `BETTER_AUTH_SECRET`** — checks whether the setting already exists; generates and sets it if missing.
10. **Update `APP_URL`** — patches the real function-app URL into its own app settings.
Re-running the workflow is safe — Bicep uses create-or-update semantics and the secret step skips if the setting is already present.
---
## 5 — Post-deploy: configure S3 storage
1. Open your function app URL in a browser and complete the ZPan setup wizard.
2. Navigate to **Admin → Storages** and add your S3-compatible bucket credentials.
### Verify the deployment
```sh
curl https://<your-func-app>.azurewebsites.net/api/health
# → {"status":"ok"}
```
---
## Local development against a Turso database
```sh
TURSO_DATABASE_URL=libsql://your-db.turso.io \
TURSO_AUTH_TOKEN=your-token \
BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
npm run dev:node
```
### Local Azure Functions emulation
```sh
npm run build:azure
cd azure-functions
func start
```
Requires the [Azure Functions Core Tools v4](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local) and a local `.env` file (or environment variables) with `TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN`, and `BETTER_AUTH_SECRET`.
+32
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1022.0",
"@aws-sdk/s3-request-presigner": "^3.1022.0",
"@azure/functions": "^4.12.0",
"@better-auth/api-key": "^1.6.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -1017,6 +1018,37 @@
"node": ">=18.0.0"
}
},
"node_modules/@azure/functions": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.12.0.tgz",
"integrity": "sha512-aHBSvEDHOUhLhkivPiotoYfE6WPZdutv9OnXEkSqYtyWjbf1k/DoQ/Z9swqIbyaBSvh2xOR8ARKj5CR/2jqEOw==",
"license": "MIT",
"dependencies": {
"@azure/functions-extensions-base": "0.2.0",
"cookie": "^0.7.0"
},
"engines": {
"node": ">=20.0"
}
},
"node_modules/@azure/functions-extensions-base": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@azure/functions-extensions-base/-/functions-extensions-base-0.2.0.tgz",
"integrity": "sha512-ncCkHBNQYJa93dBIh+toH0v1iSgCzSo9tr94s6SMBe7DPWREkaWh8cq33A5P4rPSFX1g5W+3SPvIzDr/6/VOWQ==",
"license": "MIT",
"engines": {
"node": ">=18.0"
}
},
"node_modules/@azure/functions/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+2
View File
@@ -11,6 +11,7 @@
"build:lambda": "tsup server/entry-lambda.ts --format cjs --outDir dist-lambda --external @libsql/client",
"build:vercel": "vite build --mode node && tsup server/entry-vercel.ts --format esm --outDir api --external @libsql/client",
"build:netlify": "tsup server/entry-netlify.ts --format esm --outDir netlify/functions --external @libsql/client",
"build:azure": "vite build && tsup server/entry-azure.ts --format esm --outDir azure-functions --external @azure/functions --external @libsql/client && cp server/azure-host.json azure-functions/host.json && cp -r dist azure-functions/dist",
"deploy": "npm run db:migrate:d1:prod && wrangler deploy",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
@@ -36,6 +37,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1022.0",
"@aws-sdk/s3-request-presigner": "^3.1022.0",
"@azure/functions": "^4.12.0",
"@better-auth/api-key": "^1.6.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
+15
View File
@@ -0,0 +1,15 @@
{
"version": "2.0",
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
},
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { HttpRequest, InvocationContext } from '@azure/functions'
import { app } from '@azure/functions'
import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
import { createBootstrap } from './bootstrap'
import { createLibsqlPlatform } from './platform/libsql'
const TURSO_DATABASE_URL = process.env.TURSO_DATABASE_URL
if (!TURSO_DATABASE_URL) {
throw new Error('TURSO_DATABASE_URL is required for the Azure Functions deployment.')
}
const platform = await createLibsqlPlatform({
TURSO_DATABASE_URL,
TURSO_AUTH_TOKEN: process.env.TURSO_AUTH_TOKEN,
})
const apiApp = await createBootstrap(platform)
const server = new Hono()
server.route('/', apiApp)
server.use('/*', serveStatic({ root: './dist' }))
server.get('/*', serveStatic({ root: './dist', path: 'index.html' }))
app.http('zpan', {
methods: ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'],
authLevel: 'anonymous',
route: '{*path}',
handler: async (request: HttpRequest, _context: InvocationContext): Promise<Response> => {
// Construct a standards-compliant Request so Hono body-reading works correctly
// on POST/PUT/PATCH routes. Azure HttpRequest v4 has additional properties and
// a single-read stream; wrapping it avoids silent breakage on body-reading routes.
// The body cast and duplex option are required for Node 22's undici-based fetch.
const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
const webReq = new Request(request.url, {
method: request.method,
headers: request.headers,
body: hasBody ? (request.body as BodyInit) : undefined,
...(hasBody ? { duplex: 'half' } : {}),
} as RequestInit)
return server.fetch(webReq)
},
})