From a5e31f5986fba16fbb2550fd3ca874ba34ad003d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Sat, 28 Feb 2026 22:47:39 +0100 Subject: [PATCH 1/4] feat: azure deployment adapter --- .gitignore | 1 + docs/.gitignore | 2 + examples/photos/.gitignore | 2 + packages/create-react-server/steps/deploy.mjs | 7 + packages/react-server/adapters/README.md | 10 + .../adapters/azure/functions/entry.mjs | 55 ++++ .../react-server/adapters/azure/index.mjs | 255 ++++++++++++++++++ packages/react-server/package.json | 4 + 8 files changed, 336 insertions(+) create mode 100644 packages/react-server/adapters/azure/functions/entry.mjs create mode 100644 packages/react-server/adapters/azure/index.mjs diff --git a/.gitignore b/.gitignore index e8517e85..2af87c36 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ netlify netlify.toml !packages/react-server/adapters/netlify deno.lock +.azure .bun .deno *.pem diff --git a/docs/.gitignore b/docs/.gitignore index e985853e..34d37aa8 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1 +1,3 @@ .vercel + +.env \ No newline at end of file diff --git a/examples/photos/.gitignore b/examples/photos/.gitignore index e985853e..34d37aa8 100644 --- a/examples/photos/.gitignore +++ b/examples/photos/.gitignore @@ -1 +1,3 @@ .vercel + +.env \ No newline at end of file diff --git a/packages/create-react-server/steps/deploy.mjs b/packages/create-react-server/steps/deploy.mjs index fc4ead40..25f6a4c4 100644 --- a/packages/create-react-server/steps/deploy.mjs +++ b/packages/create-react-server/steps/deploy.mjs @@ -53,6 +53,11 @@ export default async (context) => { value: "deno", description: "Deploy to Deno runtime", }, + { + name: "Azure Static Web Apps", + value: "azure", + description: "Deploy to Azure Static Web Apps", + }, { name: "AWS", value: "aws", @@ -73,6 +78,7 @@ export default async (context) => { vercel: "Vercel", netlify: "Netlify", cloudflare: "Cloudflare Workers/Pages", + azure: "Azure Static Web Apps", bun: "Bun", deno: "Deno", }; @@ -80,6 +86,7 @@ export default async (context) => { vercel: [".vercel", "vercel.json"], netlify: ["netlify.toml", "netlify", ".netlify"], cloudflare: [".cloudflare", ".wrangler", "wrangler.toml"], + azure: [".azure", "staticwebapp.config.json"], bun: [".bun"], deno: [".deno"], }; diff --git a/packages/react-server/adapters/README.md b/packages/react-server/adapters/README.md index 6a14143c..f4bb7ba2 100644 --- a/packages/react-server/adapters/README.md +++ b/packages/react-server/adapters/README.md @@ -323,6 +323,16 @@ The edge runtime does **not** serve static files. Your entry must handle this: - **Deploy**: `deno run --allow-net --allow-read --allow-env --allow-sys .deno/start.mjs` - **Notes**: Standalone runtime, no cloud config needed. Static routes are hardcoded at build time. Uses `deno.json` with `nodeModulesDir: "none"` — no `node_modules` required. +### Azure (`adapters/azure/`) + +- **Runtime**: Node.js serverless (no edge build) +- **Entry**: `functions/index.mjs` — uses `@lazarv/react-server/node` (Node middleware mode) +- **Output**: `.azure/static/` + `.azure/functions/server/` +- **Config**: Generates `staticwebapp.config.json`, `host.json`, and `local.settings.json`; merges with `react-server.azure.json` +- **Static files**: Handled by Azure Static Web Apps CDN via `navigationFallback` routing +- **Deploy**: `swa deploy .azure/static --api-location .azure/functions` +- **Notes**: Uses Node mode + `copy.dependencies()` (not edge build). Targets Azure Static Web Apps with a managed API backend. The `staticwebapp.config.json` routes all non-static requests to the serverless function. + ## Step-by-Step: Creating a New Adapter 1. **Create the directory**: `adapters//` diff --git a/packages/react-server/adapters/azure/functions/entry.mjs b/packages/react-server/adapters/azure/functions/entry.mjs new file mode 100644 index 00000000..830125d1 --- /dev/null +++ b/packages/react-server/adapters/azure/functions/entry.mjs @@ -0,0 +1,55 @@ +import { reactServer } from "@lazarv/react-server/edge"; +import { createContext } from "@lazarv/react-server/http"; + +let serverPromise = null; + +export default async (request, context) => { + try { + if (!serverPromise) { + serverPromise = reactServer({ + origin: + process.env.ORIGIN || + `${new URL(request.url).protocol}//${new URL(request.url).host}`, + outDir: "../", + }); + } + + const { handler } = await serverPromise; + + const origin = + process.env.ORIGIN || + `${new URL(request.url).protocol}//${new URL(request.url).host}`; + const httpContext = createContext(request, { + origin, + runtime: "azure", + platformExtras: context ?? {}, + }); + + const response = await handler(httpContext); + + if (!response) { + return new Response("Not Found", { status: 404 }); + } + + // Add set-cookie headers + if (httpContext._setCookies?.length) { + const headers = new Headers(response.headers); + for (const c of httpContext._setCookies) { + headers.append("set-cookie", c); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } + + return response; + } catch (e) { + console.error(e); + return new Response(e.message || "Internal Server Error", { + status: 500, + headers: { "Content-Type": "text/plain" }, + }); + } +}; diff --git a/packages/react-server/adapters/azure/index.mjs b/packages/react-server/adapters/azure/index.mjs new file mode 100644 index 00000000..1e469a00 --- /dev/null +++ b/packages/react-server/adapters/azure/index.mjs @@ -0,0 +1,255 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { cp } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import * as sys from "@lazarv/react-server/lib/sys.mjs"; +import { + banner, + createAdapter, + message, + success, + writeJSON, +} from "@lazarv/react-server/adapters/core"; + +const cwd = sys.cwd(); +const outDir = join(cwd, ".azure"); +const outStaticDir = join(outDir, "static"); +const adapterDir = dirname(fileURLToPath(import.meta.url)); + +/** + * Build options for the Azure adapter. + * Uses edge build to bundle the server into a single file. + */ +export const buildOptions = { + edge: { + entry: join(adapterDir, "functions/entry.mjs"), + }, +}; + +export const adapter = createAdapter({ + name: "Azure", + outDir, + outStaticDir, + handler: async function ({ adapterOptions, copy, options }) { + banner("building Azure Functions", { emoji: "⚡" }); + + const outServerDir = join(outDir, "functions/server"); + + // Copy server files (includes the bundled edge.mjs, manifests, etc.) + await copy.server(outServerDir); + + message("creating", "server function module"); + + // Generate the Azure Functions wrapper that bridges Azure's (context, req) + // model to the standard fetch handler in the bundled edge.mjs. + // Azure Functions v3 doesn't provide a standard Web Request, so we + // construct one from the Azure request object and convert the Response back. + const entryFile = join(outServerDir, "index.mjs"); + writeFileSync( + entryFile, + `import handler from "./.react-server/server/edge.mjs"; + +export default async function (context, req) { + try { + // Use the original URL when SWA rewrites via navigationFallback + const originalUrl = req.headers["x-ms-original-url"] || req.url; + const proto = req.headers["x-forwarded-proto"] || "https"; + const host = + req.headers["x-forwarded-host"] || req.headers.host || "localhost"; + + let url; + try { + url = new URL(originalUrl); + } catch { + url = new URL(originalUrl, proto + "://" + host); + } + + const init = { + method: req.method, + headers: req.headers, + }; + if ( + req.method !== "GET" && + req.method !== "HEAD" && + (req.rawBody || req.body) + ) { + init.body = + req.rawBody ?? + (typeof req.body === "string" ? req.body : JSON.stringify(req.body)); + } + + const request = new Request(url.href, init); + const response = await handler(request); + + const headers = {}; + response.headers.forEach((value, key) => { + if (key in headers) { + headers[key] = Array.isArray(headers[key]) + ? [...headers[key], value] + : [headers[key], value]; + } else { + headers[key] = value; + } + }); + + const body = Buffer.from(await response.arrayBuffer()); + + context.res = { + status: response.status, + headers, + body, + isRaw: true, + }; + } catch (e) { + console.error(e); + context.res = { + status: 500, + headers: { "Content-Type": "text/plain" }, + body: e.message || "Internal Server Error", + }; + } +} +` + ); + + // Create function.json for Azure Functions v3 HTTP trigger + await writeJSON(join(outServerDir, "function.json"), { + bindings: [ + { + authLevel: "anonymous", + type: "httpTrigger", + direction: "in", + name: "req", + methods: ["get", "post", "put", "delete", "patch", "head", "options"], + route: "{*path}", + }, + { + type: "http", + direction: "out", + name: "res", + }, + ], + }); + + // Create package.json at the functions root for ESM support + writeFileSync( + join(outDir, "functions/package.json"), + JSON.stringify({ type: "module" }, null, 2) + ); + + success("server function initialized"); + + banner("creating Azure configuration", { emoji: "⚙️" }); + + // Generate host.json for Azure Functions + message("creating", "host.json"); + const hostJson = { + version: "2.0", + extensionBundle: { + id: "Microsoft.Azure.Functions.ExtensionBundle", + version: "[4.*, 5.0.0)", + }, + ...adapterOptions?.host, + }; + await writeJSON(join(outDir, "functions/host.json"), hostJson); + success("host.json created"); + + // Generate staticwebapp.config.json for Azure Static Web Apps routing + message("creating", "staticwebapp.config.json"); + const swaConfig = { + routes: [ + { + route: "/", + rewrite: "/api/server", + }, + { + route: "/assets/*", + headers: { + "Cache-Control": "public, max-age=31536000, immutable", + }, + }, + { + route: "/client/*", + headers: { + "Cache-Control": "public, max-age=31536000, immutable", + }, + }, + ...(adapterOptions?.routes ?? []), + ], + navigationFallback: { + rewrite: "/api/server", + exclude: ["/assets/*", "/client/*"], + }, + platform: { + apiRuntime: "node:20", + ...adapterOptions?.platform, + }, + ...adapterOptions?.staticwebapp, + }; + + // Merge with user's react-server.azure.json config if it exists + const userConfigPath = join(cwd, "react-server.azure.json"); + if (existsSync(userConfigPath)) { + try { + const userConfig = JSON.parse(readFileSync(userConfigPath, "utf-8")); + Object.assign(swaConfig, userConfig); + message( + "merging", + "existing react-server.azure.json with adapter config" + ); + } catch { + // Ignore parsing errors + } + } + + await writeJSON(join(outDir, "staticwebapp.config.json"), swaConfig); + success("staticwebapp.config.json created"); + + // Copy staticwebapp.config.json to the static dir so SWA picks it up + await cp( + join(outDir, "staticwebapp.config.json"), + join(outStaticDir, "staticwebapp.config.json") + ); + + // Azure SWA deployment tool requires an index.html in the static directory. + // Create a minimal placeholder if one doesn't already exist from pre-rendering. + // The route rule for "/" rewrites to the API function, so this file is not + // actually served for the root path. + const indexHtmlPath = join(outStaticDir, "index.html"); + if (!existsSync(indexHtmlPath)) { + message("creating", "fallback index.html"); + writeFileSync(indexHtmlPath, ""); + } + + // Generate local.settings.json for local development with Azure Functions + message("creating", "local.settings.json"); + await writeJSON(join(outDir, "functions/local.settings.json"), { + IsEncrypted: false, + Values: { + AzureWebJobsStorage: "", + FUNCTIONS_WORKER_RUNTIME: "node", + ...(options.sourcemap ? { NODE_OPTIONS: "--enable-source-maps" } : {}), + ...adapterOptions?.env, + }, + }); + success("local.settings.json created"); + }, + deploy: { + command: "swa", + args: [ + "deploy", + ".azure/static", + "--api-location", + ".azure/functions", + "--api-language", + "node", + "--api-version", + "20", + ], + }, +}); + +export default function defineConfig(adapterOptions) { + return async (_, root, options) => adapter(adapterOptions, root, options); +} diff --git a/packages/react-server/package.json b/packages/react-server/package.json index 2e285901..11036fd6 100644 --- a/packages/react-server/package.json +++ b/packages/react-server/package.json @@ -130,6 +130,10 @@ "types": "./adapters/adapter.d.ts", "default": "./adapters/vercel/index.mjs" }, + "./adapters/azure": { + "types": "./adapters/adapter.d.ts", + "default": "./adapters/azure/index.mjs" + }, "./worker": { "types": "./worker/index.d.ts", "default": "./worker/index.mjs" From 3db5c02fd34c9ba192f4d6ece5c55cb31e967428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Sun, 1 Mar 2026 00:33:17 +0100 Subject: [PATCH 2/4] feat: azure deployment using bicep --- .gitignore | 1 + packages/create-react-server/steps/deploy.mjs | 14 +- packages/react-server/adapters/README.md | 28 +- .../adapters/azure-swa/functions/entry.mjs | 55 ++ .../react-server/adapters/azure-swa/index.mjs | 255 ++++++++ .../adapters/azure/functions/entry.mjs | 14 +- .../react-server/adapters/azure/index.mjs | 598 ++++++++++++------ packages/react-server/adapters/core.d.ts | 8 +- packages/react-server/adapters/core.mjs | 11 +- packages/react-server/package.json | 4 + 10 files changed, 792 insertions(+), 196 deletions(-) create mode 100644 packages/react-server/adapters/azure-swa/functions/entry.mjs create mode 100644 packages/react-server/adapters/azure-swa/index.mjs diff --git a/.gitignore b/.gitignore index 2af87c36..c1c8aa6e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ netlify.toml !packages/react-server/adapters/netlify deno.lock .azure +.azure-swa .bun .deno *.pem diff --git a/packages/create-react-server/steps/deploy.mjs b/packages/create-react-server/steps/deploy.mjs index 25f6a4c4..e22daf67 100644 --- a/packages/create-react-server/steps/deploy.mjs +++ b/packages/create-react-server/steps/deploy.mjs @@ -54,8 +54,14 @@ export default async (context) => { description: "Deploy to Deno runtime", }, { - name: "Azure Static Web Apps", + name: "Azure Functions", value: "azure", + description: + "Deploy to Azure Functions with streaming support", + }, + { + name: "Azure Static Web Apps", + value: "azure-swa", description: "Deploy to Azure Static Web Apps", }, { @@ -78,7 +84,8 @@ export default async (context) => { vercel: "Vercel", netlify: "Netlify", cloudflare: "Cloudflare Workers/Pages", - azure: "Azure Static Web Apps", + azure: "Azure Functions", + "azure-swa": "Azure Static Web Apps", bun: "Bun", deno: "Deno", }; @@ -86,7 +93,8 @@ export default async (context) => { vercel: [".vercel", "vercel.json"], netlify: ["netlify.toml", "netlify", ".netlify"], cloudflare: [".cloudflare", ".wrangler", "wrangler.toml"], - azure: [".azure", "staticwebapp.config.json"], + azure: [".azure"], + "azure-swa": [".azure-swa", "staticwebapp.config.json"], bun: [".bun"], deno: [".deno"], }; diff --git a/packages/react-server/adapters/README.md b/packages/react-server/adapters/README.md index f4bb7ba2..60565c74 100644 --- a/packages/react-server/adapters/README.md +++ b/packages/react-server/adapters/README.md @@ -325,13 +325,31 @@ The edge runtime does **not** serve static files. Your entry must handle this: ### Azure (`adapters/azure/`) -- **Runtime**: Node.js serverless (no edge build) -- **Entry**: `functions/index.mjs` — uses `@lazarv/react-server/node` (Node middleware mode) -- **Output**: `.azure/static/` + `.azure/functions/server/` +- **Runtime**: Edge (Azure Functions v4 programming model with streaming) +- **Entry**: `functions/entry.mjs` — edge entry using `@lazarv/react-server/edge` with `createContext` +- **Output**: `.azure/static/` + `.azure/server/.react-server/` + `.azure/src/functions/server.mjs` +- **Config**: Generates `host.json`, `package.json` (with `@azure/functions` dependency), `local.settings.json`, and `main.bicep` (IaC template) +- **Static files**: Build-time route map in generated `src/functions/server.mjs` — serves static files from disk via `readFileSync()` +- **Deploy**: `func azure functionapp publish --javascript` +- **Provisioning**: When deploying with `--deploy`, automatically provisions Azure resources (resource group, storage account, consumption plan, function app) using a Bicep template via `az deployment group create`. Skips provisioning if the function app already exists. +- **Adapter options**: + - `appName` — Function App name (falls back to `package.json` name) + - `resourceGroup` — Azure resource group name (default: `-rg`) + - `location` — Azure region (default: `"eastus"`) + - `storageName` — Storage account name (default: derived from appName, lowercase alphanumeric, max 24 chars) + - `host` — Extra properties to merge into `host.json` + - `env` — Extra environment variables for `local.settings.json` +- **Notes**: Uses edge build for single-file bundling. The v4 programming model's `app.http()` supports returning `ReadableStream` bodies, enabling response streaming. Static files are served by the function itself (no separate CDN). Requires Azure Functions Core Tools v4 (`npm i -g azure-functions-core-tools@4`) and Azure CLI (`az`) for auto-provisioning. + +### Azure SWA (`adapters/azure-swa/`) + +- **Runtime**: Edge (bundled into single file, served via Azure SWA managed functions) +- **Entry**: `functions/entry.mjs` — edge entry using `@lazarv/react-server/edge` with `createContext` +- **Output**: `.azure-swa/static/` + `.azure-swa/functions/server/` - **Config**: Generates `staticwebapp.config.json`, `host.json`, and `local.settings.json`; merges with `react-server.azure.json` - **Static files**: Handled by Azure Static Web Apps CDN via `navigationFallback` routing -- **Deploy**: `swa deploy .azure/static --api-location .azure/functions` -- **Notes**: Uses Node mode + `copy.dependencies()` (not edge build). Targets Azure Static Web Apps with a managed API backend. The `staticwebapp.config.json` routes all non-static requests to the serverless function. +- **Deploy**: `swa deploy .azure-swa/static --api-location .azure-swa/functions --api-language node --api-version 20` +- **Notes**: Uses edge build. Targets Azure Static Web Apps with managed functions. Does **not** support response streaming (SWA buffers responses). Good for simpler/static-heavy apps. ## Step-by-Step: Creating a New Adapter diff --git a/packages/react-server/adapters/azure-swa/functions/entry.mjs b/packages/react-server/adapters/azure-swa/functions/entry.mjs new file mode 100644 index 00000000..830125d1 --- /dev/null +++ b/packages/react-server/adapters/azure-swa/functions/entry.mjs @@ -0,0 +1,55 @@ +import { reactServer } from "@lazarv/react-server/edge"; +import { createContext } from "@lazarv/react-server/http"; + +let serverPromise = null; + +export default async (request, context) => { + try { + if (!serverPromise) { + serverPromise = reactServer({ + origin: + process.env.ORIGIN || + `${new URL(request.url).protocol}//${new URL(request.url).host}`, + outDir: "../", + }); + } + + const { handler } = await serverPromise; + + const origin = + process.env.ORIGIN || + `${new URL(request.url).protocol}//${new URL(request.url).host}`; + const httpContext = createContext(request, { + origin, + runtime: "azure", + platformExtras: context ?? {}, + }); + + const response = await handler(httpContext); + + if (!response) { + return new Response("Not Found", { status: 404 }); + } + + // Add set-cookie headers + if (httpContext._setCookies?.length) { + const headers = new Headers(response.headers); + for (const c of httpContext._setCookies) { + headers.append("set-cookie", c); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } + + return response; + } catch (e) { + console.error(e); + return new Response(e.message || "Internal Server Error", { + status: 500, + headers: { "Content-Type": "text/plain" }, + }); + } +}; diff --git a/packages/react-server/adapters/azure-swa/index.mjs b/packages/react-server/adapters/azure-swa/index.mjs new file mode 100644 index 00000000..9638f9f5 --- /dev/null +++ b/packages/react-server/adapters/azure-swa/index.mjs @@ -0,0 +1,255 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { cp } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import * as sys from "@lazarv/react-server/lib/sys.mjs"; +import { + banner, + createAdapter, + message, + success, + writeJSON, +} from "@lazarv/react-server/adapters/core"; + +const cwd = sys.cwd(); +const outDir = join(cwd, ".azure-swa"); +const outStaticDir = join(outDir, "static"); +const adapterDir = dirname(fileURLToPath(import.meta.url)); + +/** + * Build options for the Azure SWA adapter. + * Uses edge build to bundle the server into a single file. + */ +export const buildOptions = { + edge: { + entry: join(adapterDir, "functions/entry.mjs"), + }, +}; + +export const adapter = createAdapter({ + name: "Azure Static Web Apps", + outDir, + outStaticDir, + handler: async function ({ adapterOptions, copy, options }) { + banner("building Azure Functions", { emoji: "⚡" }); + + const outServerDir = join(outDir, "functions/server"); + + // Copy server files (includes the bundled edge.mjs, manifests, etc.) + await copy.server(outServerDir); + + message("creating", "server function module"); + + // Generate the Azure Functions wrapper that bridges Azure's (context, req) + // model to the standard fetch handler in the bundled edge.mjs. + // Azure Functions v3 doesn't provide a standard Web Request, so we + // construct one from the Azure request object and convert the Response back. + const entryFile = join(outServerDir, "index.mjs"); + writeFileSync( + entryFile, + `import handler from "./.react-server/server/edge.mjs"; + +export default async function (context, req) { + try { + // Use the original URL when SWA rewrites via navigationFallback + const originalUrl = req.headers["x-ms-original-url"] || req.url; + const proto = req.headers["x-forwarded-proto"] || "https"; + const host = + req.headers["x-forwarded-host"] || req.headers.host || "localhost"; + + let url; + try { + url = new URL(originalUrl); + } catch { + url = new URL(originalUrl, proto + "://" + host); + } + + const init = { + method: req.method, + headers: req.headers, + }; + if ( + req.method !== "GET" && + req.method !== "HEAD" && + (req.rawBody || req.body) + ) { + init.body = + req.rawBody ?? + (typeof req.body === "string" ? req.body : JSON.stringify(req.body)); + } + + const request = new Request(url.href, init); + const response = await handler(request); + + const headers = {}; + response.headers.forEach((value, key) => { + if (key in headers) { + headers[key] = Array.isArray(headers[key]) + ? [...headers[key], value] + : [headers[key], value]; + } else { + headers[key] = value; + } + }); + + const body = Buffer.from(await response.arrayBuffer()); + + context.res = { + status: response.status, + headers, + body, + isRaw: true, + }; + } catch (e) { + console.error(e); + context.res = { + status: 500, + headers: { "Content-Type": "text/plain" }, + body: e.message || "Internal Server Error", + }; + } +} +` + ); + + // Create function.json for Azure Functions v3 HTTP trigger + await writeJSON(join(outServerDir, "function.json"), { + bindings: [ + { + authLevel: "anonymous", + type: "httpTrigger", + direction: "in", + name: "req", + methods: ["get", "post", "put", "delete", "patch", "head", "options"], + route: "{*path}", + }, + { + type: "http", + direction: "out", + name: "res", + }, + ], + }); + + // Create package.json at the functions root for ESM support + writeFileSync( + join(outDir, "functions/package.json"), + JSON.stringify({ type: "module" }, null, 2) + ); + + success("server function initialized"); + + banner("creating Azure configuration", { emoji: "⚙️" }); + + // Generate host.json for Azure Functions + message("creating", "host.json"); + const hostJson = { + version: "2.0", + extensionBundle: { + id: "Microsoft.Azure.Functions.ExtensionBundle", + version: "[4.*, 5.0.0)", + }, + ...adapterOptions?.host, + }; + await writeJSON(join(outDir, "functions/host.json"), hostJson); + success("host.json created"); + + // Generate staticwebapp.config.json for Azure Static Web Apps routing + message("creating", "staticwebapp.config.json"); + const swaConfig = { + routes: [ + { + route: "/", + rewrite: "/api/server", + }, + { + route: "/assets/*", + headers: { + "Cache-Control": "public, max-age=31536000, immutable", + }, + }, + { + route: "/client/*", + headers: { + "Cache-Control": "public, max-age=31536000, immutable", + }, + }, + ...(adapterOptions?.routes ?? []), + ], + navigationFallback: { + rewrite: "/api/server", + exclude: ["/assets/*", "/client/*"], + }, + platform: { + apiRuntime: "node:20", + ...adapterOptions?.platform, + }, + ...adapterOptions?.staticwebapp, + }; + + // Merge with user's react-server.azure.json config if it exists + const userConfigPath = join(cwd, "react-server.azure.json"); + if (existsSync(userConfigPath)) { + try { + const userConfig = JSON.parse(readFileSync(userConfigPath, "utf-8")); + Object.assign(swaConfig, userConfig); + message( + "merging", + "existing react-server.azure.json with adapter config" + ); + } catch { + // Ignore parsing errors + } + } + + await writeJSON(join(outDir, "staticwebapp.config.json"), swaConfig); + success("staticwebapp.config.json created"); + + // Copy staticwebapp.config.json to the static dir so SWA picks it up + await cp( + join(outDir, "staticwebapp.config.json"), + join(outStaticDir, "staticwebapp.config.json") + ); + + // Azure SWA deployment tool requires an index.html in the static directory. + // Create a minimal placeholder if one doesn't already exist from pre-rendering. + // The route rule for "/" rewrites to the API function, so this file is not + // actually served for the root path. + const indexHtmlPath = join(outStaticDir, "index.html"); + if (!existsSync(indexHtmlPath)) { + message("creating", "fallback index.html"); + writeFileSync(indexHtmlPath, ""); + } + + // Generate local.settings.json for local development with Azure Functions + message("creating", "local.settings.json"); + await writeJSON(join(outDir, "functions/local.settings.json"), { + IsEncrypted: false, + Values: { + AzureWebJobsStorage: "", + FUNCTIONS_WORKER_RUNTIME: "node", + ...(options.sourcemap ? { NODE_OPTIONS: "--enable-source-maps" } : {}), + ...adapterOptions?.env, + }, + }); + success("local.settings.json created"); + }, + deploy: { + command: "swa", + args: [ + "deploy", + ".azure-swa/static", + "--api-location", + ".azure-swa/functions", + "--api-language", + "node", + "--api-version", + "20", + ], + }, +}); + +export default function defineConfig(adapterOptions) { + return async (_, root, options) => adapter(adapterOptions, root, options); +} diff --git a/packages/react-server/adapters/azure/functions/entry.mjs b/packages/react-server/adapters/azure/functions/entry.mjs index 830125d1..79024948 100644 --- a/packages/react-server/adapters/azure/functions/entry.mjs +++ b/packages/react-server/adapters/azure/functions/entry.mjs @@ -5,24 +5,22 @@ let serverPromise = null; export default async (request, context) => { try { + const url = new URL(request.url); + if (!serverPromise) { serverPromise = reactServer({ - origin: - process.env.ORIGIN || - `${new URL(request.url).protocol}//${new URL(request.url).host}`, + origin: process.env.ORIGIN || `${url.protocol}//${url.host}`, outDir: "../", }); } const { handler } = await serverPromise; - const origin = - process.env.ORIGIN || - `${new URL(request.url).protocol}//${new URL(request.url).host}`; + const origin = process.env.ORIGIN || `${url.protocol}//${url.host}`; const httpContext = createContext(request, { origin, runtime: "azure", - platformExtras: context ?? {}, + platformExtras: { invocationContext: context }, }); const response = await handler(httpContext); @@ -46,7 +44,7 @@ export default async (request, context) => { return response; } catch (e) { - console.error(e); + console.error("Request handler error:", e); return new Response(e.message || "Internal Server Error", { status: 500, headers: { "Content-Type": "text/plain" }, diff --git a/packages/react-server/adapters/azure/index.mjs b/packages/react-server/adapters/azure/index.mjs index 1e469a00..a807d934 100644 --- a/packages/react-server/adapters/azure/index.mjs +++ b/packages/react-server/adapters/azure/index.mjs @@ -1,13 +1,15 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { cp } from "node:fs/promises"; +import { existsSync, readFileSync } from "node:fs"; +import { execSync } from "node:child_process"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { mkdir, writeFile } from "node:fs/promises"; import * as sys from "@lazarv/react-server/lib/sys.mjs"; import { banner, createAdapter, message, + spawnCommand, success, writeJSON, } from "@lazarv/react-server/adapters/core"; @@ -15,10 +17,232 @@ import { const cwd = sys.cwd(); const outDir = join(cwd, ".azure"); const outStaticDir = join(outDir, "static"); +const outServerDir = join(outDir, "server"); const adapterDir = dirname(fileURLToPath(import.meta.url)); +function resolveAppName(adapterOptions) { + if (adapterOptions?.appName) return adapterOptions.appName; + const packageJsonPath = join(cwd, "package.json"); + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + return packageJson.name?.replace(/^@[^/]+\//, ""); + } catch { + // Ignore parsing errors + } + } + return null; +} + +function az(args) { + try { + const result = execSync(`az ${args}`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + return result.trim(); + } catch (e) { + // Capture stderr for better error messages + if (e.stderr) { + e.azError = e.stderr.toString().trim(); + } + throw e; + } +} + +function azSafe(args) { + try { + return az(args); + } catch { + return null; + } +} + +function azJSON(args) { + const result = azSafe(`${args} -o json`); + if (!result) return null; + try { + return JSON.parse(result); + } catch { + return null; + } +} + +function sanitizeStorageName(name) { + // Storage account names: 3-24 chars, lowercase alphanumeric only + return name + .toLowerCase() + .replace(/[^a-z0-9]/g, "") + .slice(0, 24); +} + +const BICEP_TEMPLATE = `@description('Name of the Function App') +param appName string + +@description('Name of the Storage Account') +param storageName string + +@description('Azure region for all resources') +param location string = resourceGroup().location + +@description('Node.js runtime version') +param nodeVersion string = '20' + +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: storageName + location: location + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + supportsHttpsTrafficOnly: true + minimumTlsVersion: 'TLS1_2' + allowBlobPublicAccess: false + } +} + +resource hostingPlan 'Microsoft.Web/serverfarms@2023-12-01' = { + name: '\${appName}-plan' + location: location + sku: { + name: 'Y1' + tier: 'Dynamic' + } + kind: 'functionapp,linux' + properties: { + reserved: true + } +} + +resource functionApp 'Microsoft.Web/sites@2023-12-01' = { + name: appName + location: location + kind: 'functionapp,linux' + properties: { + serverFarmId: hostingPlan.id + httpsOnly: true + siteConfig: { + linuxFxVersion: 'Node|\${nodeVersion}' + appSettings: [ + { + name: 'AzureWebJobsStorage' + value: 'DefaultEndpointsProtocol=https;AccountName=\${storageAccount.name};EndpointSuffix=\${environment().suffixes.storage};AccountKey=\${storageAccount.listKeys().keys[0].value}' + } + { + name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING' + value: 'DefaultEndpointsProtocol=https;AccountName=\${storageAccount.name};EndpointSuffix=\${environment().suffixes.storage};AccountKey=\${storageAccount.listKeys().keys[0].value}' + } + { + name: 'WEBSITE_CONTENTSHARE' + value: toLower(appName) + } + { + name: 'FUNCTIONS_EXTENSION_VERSION' + value: '~4' + } + { + name: 'FUNCTIONS_WORKER_RUNTIME' + value: 'node' + } + { + name: 'WEBSITE_NODE_DEFAULT_VERSION' + value: '~\${nodeVersion}' + } + { + name: 'AzureWebJobsFeatureFlags' + value: 'EnableWorkerIndexing' + } + ] + } + } +} + +output functionAppName string = functionApp.name +output functionAppHostName string = functionApp.properties.defaultHostName +`; + +async function provision(appName, adapterOptions) { + banner("provisioning Azure resources", { emoji: "☁️" }); + + // Check az CLI is available and logged in + const account = azJSON("account show"); + if (!account) { + throw new Error( + "Azure CLI is not installed or you are not logged in.\\n" + + " Install: https://aka.ms/install-azure-cli\\n" + + " Login: az login" + ); + } + message("authenticated", account.name); + + const location = adapterOptions?.location ?? "eastus"; + const storageName = + adapterOptions?.storageName ?? sanitizeStorageName(`${appName}store`); + + // Check if function app already exists (in specified or any resource group) + let resourceGroup = adapterOptions?.resourceGroup; + + if (resourceGroup) { + const existingApp = azJSON( + `functionapp show --name ${appName} --resource-group ${resourceGroup}` + ); + if (existingApp) { + success( + `function app "${appName}" already exists in "${resourceGroup}", skipping provisioning` + ); + return; + } + } else { + // Search all resource groups for this function app + const apps = azJSON(`functionapp list --query "[?name=='${appName}']"`); + if (apps && apps.length > 0) { + const existingRg = apps[0].resourceGroup; + success( + `function app "${appName}" already exists in "${existingRg}", skipping provisioning` + ); + return; + } + resourceGroup = `${appName}-rg`; + } + + // Check if resource group exists, create if not + const existingRg = azJSON(`group show --name ${resourceGroup}`); + if (!existingRg) { + message("creating", `resource group "${resourceGroup}" in ${location}`); + az(`group create --name ${resourceGroup} --location ${location}`); + success(`resource group "${resourceGroup}" created`); + } else { + message("found", `resource group "${resourceGroup}"`); + } + + // Deploy Bicep template + message("deploying", "Bicep template (storage + plan + function app)"); + const bicepPath = join(outDir, "main.bicep"); + const deployCmd = + `deployment group create ` + + `--resource-group ${resourceGroup} ` + + `--template-file ${bicepPath} ` + + `--parameters appName=${appName} storageName=${storageName} location=${location}`; + + try { + az(`${deployCmd} -o json`); + } catch (e) { + const azErr = e.azError || e.message || ""; + throw new Error( + `Bicep deployment failed.\n\n` + + ` Azure error: ${azErr}\n\n` + + ` Run manually to debug:\n` + + ` az ${deployCmd}`, + { cause: e } + ); + } + + success(`Azure resources provisioned: ${resourceGroup}/${appName}`); +} + /** - * Build options for the Azure adapter. + * Build options for the Azure Functions adapter. * Uses edge build to bundle the server into a single file. */ export const buildOptions = { @@ -28,117 +252,152 @@ export const buildOptions = { }; export const adapter = createAdapter({ - name: "Azure", + name: "Azure Functions", outDir, outStaticDir, - handler: async function ({ adapterOptions, copy, options }) { - banner("building Azure Functions", { emoji: "⚡" }); + outServerDir, + handler: async function ({ adapterOptions, files, options }) { + // Collect all static file paths for the route map + banner("generating static file manifest", { emoji: "🗺️" }); + const [staticFiles, assetFiles, clientFiles, publicFiles] = + await Promise.all([ + files.static(), + files.assets(), + files.client(), + files.public(), + ]); - const outServerDir = join(outDir, "functions/server"); + // Build the static file entries as a map from URL path to file path + const staticMap = {}; + const addFile = (urlPath, filePath) => { + staticMap[urlPath] = filePath; + }; - // Copy server files (includes the bundled edge.mjs, manifests, etc.) - await copy.server(outServerDir); + for (const f of staticFiles) { + addFile(`/${f}`, f); + if (f.endsWith("/index.html")) { + const dirPath = "/" + f.slice(0, -"/index.html".length); + addFile(dirPath || "/", f); + } else if (f === "index.html") { + addFile("/", f); + } + } + for (const f of assetFiles) addFile(`/${f}`, f); + for (const f of clientFiles) addFile(`/${f}`, f); + for (const f of publicFiles) addFile(`/${f}`, f); - message("creating", "server function module"); + success(`${Object.keys(staticMap).length} static files mapped`); - // Generate the Azure Functions wrapper that bridges Azure's (context, req) - // model to the standard fetch handler in the bundled edge.mjs. - // Azure Functions v3 doesn't provide a standard Web Request, so we - // construct one from the Azure request object and convert the Response back. - const entryFile = join(outServerDir, "index.mjs"); - writeFileSync( - entryFile, - `import handler from "./.react-server/server/edge.mjs"; + // Generate the Azure Functions v4 wrapper + // @azure/functions MUST be external (not bundled) — the Azure Functions + // runtime provides its own instance and monitors app.http() registrations. + // The bundled edge.mjs is the react-server handler; this thin wrapper + // bridges between Azure Functions and the edge handler. + banner("creating Azure Functions v4 entry", { emoji: "⚡" }); -export default async function (context, req) { - try { - // Use the original URL when SWA rewrites via navigationFallback - const originalUrl = req.headers["x-ms-original-url"] || req.url; - const proto = req.headers["x-forwarded-proto"] || "https"; - const host = - req.headers["x-forwarded-host"] || req.headers.host || "localhost"; + const staticMapJson = JSON.stringify(staticMap, null, 2); + + const functionEntry = `import { app } from "@azure/functions"; +import { readFileSync } from "node:fs"; +import { dirname, join, extname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const staticDir = join(__dirname, "../../static"); +const serverDir = join(__dirname, "../../server/.react-server"); + +const MIME_TYPES = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".webp": "image/webp", + ".avif": "image/avif", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".eot": "application/vnd.ms-fontobject", + ".xml": "application/xml", + ".txt": "text/plain; charset=utf-8", + ".map": "application/json", + ".webmanifest": "application/manifest+json", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".pdf": "application/pdf", + ".wasm": "application/wasm", +}; + +const STATIC_FILES = ${staticMapJson}; + +const CACHE_IMMUTABLE = "public, max-age=31536000, immutable"; + +process.chdir(serverDir); +const edgeHandler = (await import("../../server/.react-server/server/edge.mjs")).default; - let url; +app.http("server", { + methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], + authLevel: "anonymous", + route: "{*path}", + handler: async (request, context) => { try { - url = new URL(originalUrl); - } catch { - url = new URL(originalUrl, proto + "://" + host); - } + const url = new URL(request.url); + const pathname = decodeURIComponent(url.pathname); - const init = { - method: req.method, - headers: req.headers, - }; - if ( - req.method !== "GET" && - req.method !== "HEAD" && - (req.rawBody || req.body) - ) { - init.body = - req.rawBody ?? - (typeof req.body === "string" ? req.body : JSON.stringify(req.body)); - } + // Try to serve static files first + const staticFile = STATIC_FILES[pathname]; + if (staticFile) { + const filePath = join(staticDir, staticFile); + const ext = extname(staticFile); + const contentType = MIME_TYPES[ext] || "application/octet-stream"; + const body = readFileSync(filePath); - const request = new Request(url.href, init); - const response = await handler(request); - - const headers = {}; - response.headers.forEach((value, key) => { - if (key in headers) { - headers[key] = Array.isArray(headers[key]) - ? [...headers[key], value] - : [headers[key], value]; - } else { - headers[key] = value; - } - }); + const headers = { + "Content-Type": contentType, + "Content-Length": body.length.toString(), + }; - const body = Buffer.from(await response.arrayBuffer()); + if (pathname.startsWith("/assets/") || pathname.startsWith("/client/")) { + headers["Cache-Control"] = CACHE_IMMUTABLE; + } - context.res = { - status: response.status, - headers, - body, - isRaw: true, - }; - } catch (e) { - console.error(e); - context.res = { - status: 500, - headers: { "Content-Type": "text/plain" }, - body: e.message || "Internal Server Error", - }; - } -} -` - ); + return { status: 200, headers, body }; + } - // Create function.json for Azure Functions v3 HTTP trigger - await writeJSON(join(outServerDir, "function.json"), { - bindings: [ - { - authLevel: "anonymous", - type: "httpTrigger", - direction: "in", - name: "req", - methods: ["get", "post", "put", "delete", "patch", "head", "options"], - route: "{*path}", - }, - { - type: "http", - direction: "out", - name: "res", - }, - ], - }); + // Delegate to the react-server edge handler (supports streaming) + const response = await edgeHandler(request, context); - // Create package.json at the functions root for ESM support - writeFileSync( - join(outDir, "functions/package.json"), - JSON.stringify({ type: "module" }, null, 2) - ); + return { + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + body: response.body, + }; + } catch (e) { + context.error("Request handler error:", e); + return { + status: 500, + headers: { "Content-Type": "text/plain" }, + body: e.message || "Internal Server Error", + }; + } + }, +}); +`; - success("server function initialized"); + const srcFunctionsDir = join(outDir, "src/functions"); + await mkdir(srcFunctionsDir, { recursive: true }); + message("creating", "src/functions/server.mjs"); + await writeFile(join(srcFunctionsDir, "server.mjs"), functionEntry); + success("Azure Functions v4 entry created"); banner("creating Azure configuration", { emoji: "⚙️" }); @@ -146,107 +405,94 @@ export default async function (context, req) { message("creating", "host.json"); const hostJson = { version: "2.0", + extensions: { + http: { + routePrefix: "", + }, + }, extensionBundle: { id: "Microsoft.Azure.Functions.ExtensionBundle", version: "[4.*, 5.0.0)", }, ...adapterOptions?.host, }; - await writeJSON(join(outDir, "functions/host.json"), hostJson); + await writeJSON(join(outDir, "host.json"), hostJson); success("host.json created"); - // Generate staticwebapp.config.json for Azure Static Web Apps routing - message("creating", "staticwebapp.config.json"); - const swaConfig = { - routes: [ - { - route: "/", - rewrite: "/api/server", - }, - { - route: "/assets/*", - headers: { - "Cache-Control": "public, max-age=31536000, immutable", - }, - }, - { - route: "/client/*", - headers: { - "Cache-Control": "public, max-age=31536000, immutable", - }, - }, - ...(adapterOptions?.routes ?? []), - ], - navigationFallback: { - rewrite: "/api/server", - exclude: ["/assets/*", "/client/*"], + // Generate package.json for the function app + const appName = resolveAppName(adapterOptions); + + message("creating", "package.json"); + await writeJSON(join(outDir, "package.json"), { + name: appName ?? "react-server-app", + private: true, + type: "module", + main: "src/functions/server.mjs", + scripts: { + start: "func start", }, - platform: { - apiRuntime: "node:20", - ...adapterOptions?.platform, + dependencies: { + "@azure/functions": "^4.0.0", }, - ...adapterOptions?.staticwebapp, - }; - - // Merge with user's react-server.azure.json config if it exists - const userConfigPath = join(cwd, "react-server.azure.json"); - if (existsSync(userConfigPath)) { - try { - const userConfig = JSON.parse(readFileSync(userConfigPath, "utf-8")); - Object.assign(swaConfig, userConfig); - message( - "merging", - "existing react-server.azure.json with adapter config" - ); - } catch { - // Ignore parsing errors - } - } - - await writeJSON(join(outDir, "staticwebapp.config.json"), swaConfig); - success("staticwebapp.config.json created"); - - // Copy staticwebapp.config.json to the static dir so SWA picks it up - await cp( - join(outDir, "staticwebapp.config.json"), - join(outStaticDir, "staticwebapp.config.json") - ); + }); + success("package.json created"); - // Azure SWA deployment tool requires an index.html in the static directory. - // Create a minimal placeholder if one doesn't already exist from pre-rendering. - // The route rule for "/" rewrites to the API function, so this file is not - // actually served for the root path. - const indexHtmlPath = join(outStaticDir, "index.html"); - if (!existsSync(indexHtmlPath)) { - message("creating", "fallback index.html"); - writeFileSync(indexHtmlPath, ""); - } + // Install @azure/functions — this package CANNOT be bundled because the + // Azure Functions runtime provides its own instance and discovers + // registered functions through it. + message("installing", "@azure/functions"); + await spawnCommand("npm", ["install", "--prefix", outDir]); + success("dependencies installed"); - // Generate local.settings.json for local development with Azure Functions + // Generate local.settings.json for local development message("creating", "local.settings.json"); - await writeJSON(join(outDir, "functions/local.settings.json"), { + await writeJSON(join(outDir, "local.settings.json"), { IsEncrypted: false, Values: { AzureWebJobsStorage: "", FUNCTIONS_WORKER_RUNTIME: "node", + AzureWebJobsFeatureFlags: "EnableWorkerIndexing", ...(options.sourcemap ? { NODE_OPTIONS: "--enable-source-maps" } : {}), ...adapterOptions?.env, }, }); success("local.settings.json created"); + + // Generate Bicep template for Azure provisioning + message("creating", "main.bicep"); + await writeFile(join(outDir, "main.bicep"), BICEP_TEMPLATE); + success("main.bicep created"); }, - deploy: { - command: "swa", - args: [ - "deploy", - ".azure/static", - "--api-location", - ".azure/functions", - "--api-language", - "node", - "--api-version", - "20", - ], + deploy: async ({ adapterOptions, options }) => { + const appName = resolveAppName(adapterOptions); + + if (!appName) { + return { + command: "func", + args: ["azure", "functionapp", "publish", "", "--javascript"], + cwd: outDir, + message: + " Replace with your Azure Functions app name,\n" + + ' or set it via adapter options: adapter: ["azure", { appName: "my-app" }]\n' + + ' or add a "name" field to your package.json.\n' + + " Install Azure Functions Core Tools: npm i -g azure-functions-core-tools@4", + }; + } + + // Auto-provision Azure resources if deploying + if (options.deploy) { + await provision(appName, adapterOptions); + } + + return { + command: "func", + args: ["azure", "functionapp", "publish", appName, "--javascript"], + cwd: outDir, + afterDeploy: () => { + const url = `https://${appName}.azurewebsites.net`; + banner(`deployed to ${url}`, { emoji: "🌐" }); + }, + }; }, }); diff --git a/packages/react-server/adapters/core.d.ts b/packages/react-server/adapters/core.d.ts index b66fc5b2..ca74d422 100644 --- a/packages/react-server/adapters/core.d.ts +++ b/packages/react-server/adapters/core.d.ts @@ -6,7 +6,9 @@ declare module "@lazarv/react-server/adapters/core" { export type DeployCommandDescriptor = { command: string; args: string[]; + cwd?: string; message?: string; + afterDeploy?: () => void | Promise; }; export function createAdapter(options: { @@ -97,7 +99,11 @@ declare module "@lazarv/react-server/adapters/core" { adapterFiles: string[], reactServerDir: string ): Promise<{ src: string; dest: string }[]>; - export function spawnCommand(command: string, args: string[]): Promise; + export function spawnCommand( + command: string, + args: string[], + options?: { cwd?: string } + ): Promise; export function deepMerge>( source: T, target: Partial diff --git a/packages/react-server/adapters/core.mjs b/packages/react-server/adapters/core.mjs index 5e7f650a..fed81ce2 100644 --- a/packages/react-server/adapters/core.mjs +++ b/packages/react-server/adapters/core.mjs @@ -535,9 +535,9 @@ export async function getDependencies(adapterFiles, reactServerDir) { }); } -export async function spawnCommand(command, args) { +export async function spawnCommand(command, args, options) { const deploy = spawn(command, args, { - cwd, + cwd: options?.cwd ?? cwd, stdio: "inherit", }); await new Promise((resolve, reject) => { @@ -816,7 +816,9 @@ export function createAdapter({ const { command, args, + cwd: deployCwd, message: deployMessage, + afterDeploy, } = typeof deploy === "function" ? await deploy({ adapterOptions, options, handlerResult }) : deploy; @@ -824,7 +826,10 @@ export function createAdapter({ if (options.deploy) { banner(`deploying to ${name}`, { emoji: "🚀" }); clearProgress(); - await spawnCommand(command, args); + await spawnCommand(command, args, { cwd: deployCwd }); + if (afterDeploy) { + await afterDeploy(); + } } else { const deployCmd = `${command} ${args.join(" ")}`; const deployLabel = `🚀 Deploy to ${name} using:`; diff --git a/packages/react-server/package.json b/packages/react-server/package.json index 11036fd6..124d9c90 100644 --- a/packages/react-server/package.json +++ b/packages/react-server/package.json @@ -134,6 +134,10 @@ "types": "./adapters/adapter.d.ts", "default": "./adapters/azure/index.mjs" }, + "./adapters/azure-swa": { + "types": "./adapters/adapter.d.ts", + "default": "./adapters/azure-swa/index.mjs" + }, "./worker": { "types": "./worker/index.d.ts", "default": "./worker/index.mjs" From deb4797482981f6fb05f3e1f49b17ce0fd345a31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Sun, 1 Mar 2026 01:12:53 +0100 Subject: [PATCH 3/4] docs: update adapter index page --- docs/src/components/AdapterGrid.jsx | 59 +++++ docs/src/pages/en/(pages)/deploy/adapters.mdx | 4 +- .../src/pages/en/(pages)/deploy/azure-swa.mdx | 236 ++++++++++++++++++ docs/src/pages/en/(pages)/deploy/azure.mdx | 229 +++++++++++++++++ docs/src/pages/en/deploy.(index).mdx | 6 +- docs/src/pages/global.css | 30 +++ docs/src/pages/ja/(pages)/deploy/adapters.mdx | 4 +- docs/src/pages/ja/deploy.(index).mdx | 6 +- 8 files changed, 570 insertions(+), 4 deletions(-) create mode 100644 docs/src/components/AdapterGrid.jsx create mode 100644 docs/src/pages/en/(pages)/deploy/azure-swa.mdx create mode 100644 docs/src/pages/en/(pages)/deploy/azure.mdx diff --git a/docs/src/components/AdapterGrid.jsx b/docs/src/components/AdapterGrid.jsx new file mode 100644 index 00000000..c9d7e5c4 --- /dev/null +++ b/docs/src/components/AdapterGrid.jsx @@ -0,0 +1,59 @@ +const adapters = [ + { + name: "Vercel", + href: "/deploy/vercel", + description: "Serverless & edge functions", + }, + { + name: "Netlify", + href: "/deploy/netlify", + description: "Serverless functions & edge CDN", + }, + { + name: "Cloudflare", + href: "/deploy/cloudflare", + description: "Workers & Pages", + }, + { + name: "Bun", + href: "/deploy/bun", + description: "Standalone Bun server", + }, + { + name: "Deno", + href: "/deploy/deno", + description: "Standalone Deno server", + }, + { + name: "Azure Functions", + href: "/deploy/azure", + description: "Functions v4 with streaming", + }, + { + name: "Azure Static Web Apps", + href: "/deploy/azure-swa", + description: "Managed functions & CDN", + }, +]; + +export default function AdapterGrid() { + return ( +
+ {adapters.map(({ name, href, description }) => ( + + + {name} + + + {description} + + + ))} +
+ ); +} diff --git a/docs/src/pages/en/(pages)/deploy/adapters.mdx b/docs/src/pages/en/(pages)/deploy/adapters.mdx index 143c820c..5f5578eb 100644 --- a/docs/src/pages/en/(pages)/deploy/adapters.mdx +++ b/docs/src/pages/en/(pages)/deploy/adapters.mdx @@ -19,12 +19,14 @@ You can use adapters to configure your app for different deployment environments - [x] Cloudflare Workers/Pages - [x] Bun - [x] Deno +- [x] Azure Functions +- [x] Azure Static Web Apps ## Configuration -Add `adapter` entry to your `react-server.config.mjs` file. You can specify the name of a built-in adapter (`vercel`, `netlify`, `cloudflare`, `bun`, or `deno`) as a string, or use an external adapter package. +Add `adapter` entry to your `react-server.config.mjs` file. You can specify the name of a built-in adapter (`vercel`, `netlify`, `cloudflare`, `bun`, `deno`, `azure`, or `azure-swa`) as a string, or use an external adapter package. > **Note:** When running a production build with **Bun** or **Deno**, the corresponding adapter is automatically detected and used without any configuration. You can override this with an explicit `adapter` setting in your config or via `--adapter ` on the CLI. Use `--no-adapter` to disable auto-detection. diff --git a/docs/src/pages/en/(pages)/deploy/azure-swa.mdx b/docs/src/pages/en/(pages)/deploy/azure-swa.mdx new file mode 100644 index 00000000..ba45335e --- /dev/null +++ b/docs/src/pages/en/(pages)/deploy/azure-swa.mdx @@ -0,0 +1,236 @@ +--- +title: Azure Static Web Apps +category: Deploy +order: 7 +--- + +import Link from "../../../../components/Link.jsx"; + +# Azure Static Web Apps + +To deploy to Azure Static Web Apps (SWA), use the built-in `azure-swa` adapter. This adapter packages your app for SWA's managed functions and CDN-backed static hosting. + +> **Note:** Azure Static Web Apps does **not** support response streaming. All responses are buffered before being sent to the client. If you need streaming (React Suspense, progressive HTML), use the [Azure Functions](/deploy/azure) adapter instead. + + +## Installation + + +You need the [Azure Static Web Apps CLI](https://azure.github.io/static-web-apps-cli/) installed: + +```sh +npm install -g @azure/static-web-apps-cli +``` + +No additional packages are needed — the adapter is built into `@lazarv/react-server`. + +Add the adapter to your `react-server.config.mjs` file: + +```mjs +export default { + adapter: "azure-swa", +}; +``` + + +## Configuration + + +You can customize the adapter by passing options: + +```mjs +export default { + adapter: [ + "azure-swa", + { + host: {}, // Extra host.json properties + routes: [], // Additional SWA route rules + platform: { // Platform configuration overrides + apiRuntime: "node:20", + }, + staticwebapp: {}, // Extra staticwebapp.config.json properties + env: { // Extra environment variables + MY_API_KEY: "value", + }, + }, + ], +}; +``` + +### Configuration Options + +- `host`: Additional properties to merge into the generated `host.json`. +- `routes`: Additional route rules to add to `staticwebapp.config.json`. These are placed after the default `/`, `/assets/*`, and `/client/*` rules. +- `platform`: Override the platform configuration in `staticwebapp.config.json` (default: `{ apiRuntime: "node:20" }`). +- `staticwebapp`: Additional top-level properties to merge into `staticwebapp.config.json`. +- `env`: Additional environment variables for `local.settings.json`. + + +## Extending SWA configuration + + +To extend the generated `staticwebapp.config.json`, create a `react-server.azure.json` file in your project root. The adapter will merge it with the generated config: + +```json filename="react-server.azure.json" +{ + "responseOverrides": { + "404": { + "rewrite": "/api/server" + } + }, + "globalHeaders": { + "X-Frame-Options": "DENY" + } +} +``` + + +## Deploy + + +Build and deploy in one step: + +```sh +pnpm react-server build --deploy +``` + +Or build first and deploy manually: + +```sh +# Build +pnpm react-server build + +# Deploy +swa deploy .azure-swa/static \ + --api-location .azure-swa/functions \ + --api-language node \ + --api-version 20 +``` + +Before deploying, make sure you have an Azure Static Web Apps resource created. You can create one in the [Azure portal](https://portal.azure.com) or using the Azure CLI: + +```sh +az staticwebapp create \ + --name my-app \ + --resource-group my-rg \ + --location "eastus2" +``` + + +## How it works + + +The adapter uses an **edge build** mode, bundling your server into a single file. At build time, it: + +1. Bundles your server into `.azure-swa/functions/server/.react-server/server/edge.mjs` +2. Copies static assets into `.azure-swa/static/` +3. Generates a `functions/server/index.mjs` wrapper that bridges Azure Functions v3's `(context, req)` model to the standard fetch handler +4. Generates `function.json` (HTTP trigger), `host.json`, and `staticwebapp.config.json` +5. Creates a fallback `index.html` in the static directory (required by SWA) + +### Static file routing + +Static files are served by Azure SWA's built-in CDN. The `staticwebapp.config.json` configures: + +- `/assets/*` and `/client/*` routes with immutable cache headers +- A `navigationFallback` that rewrites all non-static requests to the `/api/server` function +- The root `/` path rewrites to `/api/server` + +This means static assets bypass the serverless function entirely, served directly from SWA's edge CDN. + + +## Output Structure + + +``` +.azure-swa/ +├── staticwebapp.config.json # SWA routing configuration +├── functions/ +│ ├── host.json # Azure Functions host config +│ ├── local.settings.json # Local dev settings +│ ├── package.json # ESM support +│ └── server/ +│ ├── function.json # HTTP trigger binding +│ ├── index.mjs # Request handler wrapper +│ └── .react-server/ # Bundled server (edge.mjs, manifests) +└── static/ + ├── staticwebapp.config.json + ├── index.html # Fallback (required by SWA) + ├── assets/ # Vite-built assets + ├── client/ # Client component bundles + └── ... # Other static files +``` + + +## Azure Functions vs. Azure SWA + + +| Feature | `azure` (Functions v4) | `azure-swa` (Static Web Apps) | +|---|---|---| +| **Streaming** | Yes | No (responses are buffered) | +| **Static files** | Served by the function | Served by CDN | +| **Auto-provisioning** | Yes (via Bicep) | Manual (portal or CLI) | +| **Cold starts** | Consumption plan latency | Managed by SWA | +| **Custom domains** | Via Function App settings | Via SWA settings | +| **Functions version** | v4 (programming model) | v3 (function.json) | + +Choose `azure` if you need streaming or want automatic resource provisioning. Choose `azure-swa` for simpler static-heavy apps where CDN-backed asset serving is more important than streaming. + + +## Troubleshooting + + +### SWA CLI not found + +Install the CLI globally: + +```sh +npm install -g @azure/static-web-apps-cli +``` + +### Responses are buffered / no streaming + +This is a limitation of Azure Static Web Apps. SWA buffers all function responses before sending them to the client. If you need streaming, switch to the [Azure Functions](/deploy/azure) adapter. + +### 404 errors on page navigation + +Make sure the `navigationFallback` is configured in `staticwebapp.config.json`. The adapter generates this automatically. If you've customized the config via `react-server.azure.json`, ensure you haven't overwritten the `navigationFallback` section. + +### Empty or broken page on root URL + +Verify that the `/` route rewrite to `/api/server` is present in `staticwebapp.config.json`. The adapter creates this by default. If the page loads but shows only ``, the function may not be running — check the function logs in the Azure portal. + +### Function not triggering + +Check that the function was deployed correctly: + +```sh +swa deploy .azure-swa/static \ + --api-location .azure-swa/functions \ + --api-language node \ + --api-version 20 \ + --verbose +``` + +Ensure that `.azure-swa/functions/server/function.json` exists and defines the HTTP trigger binding. + +### "x-ms-original-url" header issues + +Azure SWA uses `navigationFallback` to rewrite requests to the API function. The original URL is passed via the `x-ms-original-url` header. The adapter's wrapper handles this automatically. If you see incorrect URLs in your app, check that your SWA configuration's `navigationFallback.exclude` patterns aren't matching routes that should go to the function. + +### Local development + +You can test locally using the SWA CLI: + +```sh +swa start .azure-swa/static \ + --api-location .azure-swa/functions \ + --api-port 7071 +``` + +Or use Azure Functions Core Tools directly for the API: + +```sh +cd .azure-swa/functions +func start +``` diff --git a/docs/src/pages/en/(pages)/deploy/azure.mdx b/docs/src/pages/en/(pages)/deploy/azure.mdx new file mode 100644 index 00000000..20bf0690 --- /dev/null +++ b/docs/src/pages/en/(pages)/deploy/azure.mdx @@ -0,0 +1,229 @@ +--- +title: Azure +category: Deploy +order: 6 +--- + +import Link from "../../../../components/Link.jsx"; + +# Azure Functions + +To deploy to Azure Functions, use the built-in `azure` adapter. This adapter uses the Azure Functions v4 programming model with full response streaming support. + + +## Installation + + +You need the following tools installed: + +- [Azure CLI](https://aka.ms/install-azure-cli) (`az`) +- [Azure Functions Core Tools v4](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-tools?tabs=v4) (`func`) + +```sh +# Install Azure Functions Core Tools +npm install -g azure-functions-core-tools@4 --unsafe-perm true + +# Install Azure CLI (macOS) +brew install azure-cli + +# Login to Azure +az login +``` + +No additional packages are needed — the adapter is built into `@lazarv/react-server`. + +Add the adapter to your `react-server.config.mjs` file: + +```mjs +export default { + adapter: "azure", +}; +``` + + +## Configuration + + +You can customize the adapter by passing options: + +```mjs +export default { + adapter: [ + "azure", + { + appName: "my-app", // Azure Function App name + resourceGroup: "my-rg", // Azure resource group + location: "westus2", // Azure region + storageName: "myappstorage", // Storage account name + host: {}, // Extra host.json properties + env: { // Extra environment variables + MY_API_KEY: "value", + }, + }, + ], +}; +``` + +### Configuration Options + +- `appName`: Azure Function App name. Falls back to `package.json` name (without scope prefix). This must be globally unique across all of Azure. +- `resourceGroup`: Azure resource group name (default: `-rg`). +- `location`: Azure region for new resources (default: `"eastus"`). Only used when provisioning new resources. +- `storageName`: Azure Storage account name (default: derived from appName, lowercase alphanumeric only, max 24 characters). +- `host`: Additional properties to merge into the generated `host.json`. +- `env`: Additional environment variables to include in `local.settings.json`. + + +## Deploy + + +Build and deploy in one step: + +```sh +pnpm react-server build --deploy +``` + +Or build first and deploy manually: + +```sh +# Build +pnpm react-server build + +# Deploy using Azure Functions Core Tools +func azure functionapp publish --javascript +``` + +> **Note:** When deploying manually with `func`, you must run the command from the `.azure/` output directory. + + +## Automatic Provisioning + + +When you deploy with `--deploy`, the adapter automatically provisions all required Azure resources using a [Bicep](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/overview) template: + +- **Resource Group** — created if it doesn't exist +- **Storage Account** — Standard_LRS, HTTPS-only +- **Consumption Plan** — Y1 Dynamic tier (Linux), pay-per-execution +- **Function App** — Node.js 20, Functions v4 runtime + +If the Function App already exists (in any resource group), provisioning is skipped entirely. The adapter searches across all resource groups in your subscription to find existing apps. + +The generated `main.bicep` template is written to the `.azure/` directory. You can inspect or customize it before deploying manually with: + +```sh +az deployment group create \ + --resource-group \ + --template-file .azure/main.bicep \ + --parameters appName= storageName= location= +``` + + +## Response Streaming + + +This adapter fully supports response streaming. React Server Components, Suspense boundaries, and progressive HTML delivery all work out of the box. The Azure Functions v4 programming model's `app.http()` handler supports returning `ReadableStream` bodies, enabling true streaming without buffering. + +This is a key advantage over Azure Static Web Apps, which buffers all responses. + + +## How it works + + +The adapter uses an **edge build** mode, bundling your entire server into a single file. At build time, it: + +1. Bundles your server into `.azure/server/.react-server/server/edge.mjs` +2. Copies all static assets into `.azure/static/` +3. Generates a thin `src/functions/server.mjs` wrapper that: + - Imports `@azure/functions` (kept external — required by the Azure runtime) + - Registers an HTTP trigger via `app.http()` matching all methods and paths + - Serves static files from a build-time route map using `readFileSync()` + - Delegates dynamic requests to the bundled edge handler +4. Generates `host.json`, `package.json`, `local.settings.json`, and `main.bicep` +5. Runs `npm install` in `.azure/` to install `@azure/functions` + +> **Important:** The `@azure/functions` package cannot be bundled into the edge build. The Azure Functions runtime provides its own instance of this module and uses it to discover registered HTTP triggers. Bundling it would cause the runtime to see zero registered functions. + + +## Local Development + + +After building, you can test locally using Azure Functions Core Tools: + +```sh +cd .azure +func start +``` + +The generated `local.settings.json` configures the local runtime. If you built with `--sourcemap`, Node.js source maps are also enabled via `NODE_OPTIONS`. + + +## Output Structure + + +``` +.azure/ +├── host.json # Azure Functions host configuration +├── local.settings.json # Local development settings +├── main.bicep # Bicep IaC template for provisioning +├── package.json # Dependencies (@azure/functions) +├── node_modules/ # Installed dependencies +├── server/ +│ └── .react-server/ # Bundled server (edge.mjs, manifests) +├── src/ +│ └── functions/ +│ └── server.mjs # Azure Functions v4 HTTP trigger wrapper +└── static/ # Static assets (HTML, CSS, JS, images) +``` + + +## Troubleshooting + + +### "SubscriptionIsOverQuotaForSku" error + +This means your Azure subscription doesn't have quota for Dynamic (consumption) VMs in the selected region. Try a different region: + +```mjs +export default { + adapter: ["azure", { location: "westus2" }], +}; +``` + +Or request a quota increase in the [Azure portal](https://portal.azure.com/#blade/Microsoft_Azure_Capacity/QuotaMenuBlade/overview). + +### "Unable to find project root" from `func` + +The `func` CLI must run from the directory containing `host.json`. When deploying manually, make sure you `cd .azure` first. The `--deploy` flag handles this automatically. + +### Empty function list after deployment + +If `func publish` succeeds but reports no functions, this usually means `@azure/functions` was bundled instead of kept external. Verify that `.azure/node_modules/@azure/functions/` exists. The adapter handles this automatically, but if you've customized the build, ensure the package remains external. + +### Functions respond with 404 at `/api/...` + +The adapter sets `routePrefix: ""` in `host.json` to remove the default `/api/` prefix. If you see 404s at paths like `/api/server`, check that your `host.json` wasn't overwritten or that the `host` adapter option isn't re-adding the prefix. + +### Bicep deployment fails + +The full Azure error is displayed in the build output. Common causes: + +- **Region quota**: Your subscription may not have capacity in the default region. Set a different `location`. +- **Name conflicts**: Function App names must be globally unique. Try a more specific `appName`. +- **Missing providers**: Register the required resource providers: + ```sh + az provider register --namespace Microsoft.Web + az provider register --namespace Microsoft.Storage + ``` + +### Azure CLI not logged in + +If you see "Azure CLI is not installed or you are not logged in", run: + +```sh +az login +az account show # Verify you're logged in +``` + +### Slow cold starts + +Azure Functions on the Consumption plan may have cold starts of a few seconds. For lower latency, consider upgrading to a Premium plan by customizing the Bicep template in `.azure/main.bicep`. diff --git a/docs/src/pages/en/deploy.(index).mdx b/docs/src/pages/en/deploy.(index).mdx index b30ab60e..093fd151 100644 --- a/docs/src/pages/en/deploy.(index).mdx +++ b/docs/src/pages/en/deploy.(index).mdx @@ -1,9 +1,13 @@ # Deploy +import AdapterGrid from "../../components/AdapterGrid.jsx"; + When you're finished with your app, you can deploy it to the web. The framework provides a set of built-in adapters to deploy your app to different platforms. You can also use the framework's built-in server to deploy your app to your own server using Node.js. You will learn how to use [adapters](/deploy/adapters) to configure your app for different deployment environments. -You can also learn how to deploy your app to different platforms using the available built-in adapters. The framework provides adapters for [Vercel](/deploy/vercel), [Netlify](/deploy/netlify), [Cloudflare](/deploy/cloudflare), [Bun](/deploy/bun), and [Deno](/deploy/deno). +## Available Adapters + + Find more information about how to implement custom deployment adapters in the [Adapter API](/deploy/api) section. \ No newline at end of file diff --git a/docs/src/pages/global.css b/docs/src/pages/global.css index f81ba239..5945a3fb 100644 --- a/docs/src/pages/global.css +++ b/docs/src/pages/global.css @@ -289,6 +289,18 @@ article { } } + & ol { + @apply list-decimal list-inside mb-4; + + & li { + @apply pl-0 mb-2; + + &::marker { + @apply font-semibold text-indigo-500 dark:text-yellow-600; + } + } + } + & a { @apply text-indigo-500 dark:text-yellow-600 hover:underline; } @@ -501,3 +513,21 @@ footer { background-color: rgba(255, 255, 255, 0.02); } } + +.adapter-card:hover .adapter-card-title::after { + position: absolute; + display: block; + content: ""; + height: 2px; + width: 100%; + left: 50%; + bottom: -2px; + transform: translateX(-50%); + animation: 200ms both indicator; + + @apply from-rose-400 via-fuchsia-500 to-indigo-500 bg-gradient-to-r; + + @media (prefers-reduced-motion) { + animation: none; + } +} diff --git a/docs/src/pages/ja/(pages)/deploy/adapters.mdx b/docs/src/pages/ja/(pages)/deploy/adapters.mdx index fbe83d00..62011760 100644 --- a/docs/src/pages/ja/(pages)/deploy/adapters.mdx +++ b/docs/src/pages/ja/(pages)/deploy/adapters.mdx @@ -19,12 +19,14 @@ import Link from "../../../../components/Link.jsx"; - [x] Cloudflare Workers/Pages - [x] Bun - [x] Deno +- [x] Azure Functions +- [x] Azure Static Web Apps ## 設定 -`react-server.config.mjs`ファイルに `adapter` エントリを追加します。ビルトインアダプタの名前(`vercel`、`netlify`、`cloudflare`、`bun`、または `deno`)を文字列で指定するか、外部アダプタパッケージを使用できます。 +`react-server.config.mjs`ファイルに `adapter` エントリを追加します。ビルトインアダプタの名前(`vercel`、`netlify`、`cloudflare`、`bun`、`deno`、`azure`、または `azure-swa`)を文字列で指定するか、外部アダプタパッケージを使用できます。 > **Note:** **Bun** または **Deno** でプロダクションビルドを実行すると、対応するアダプタが自動的に検出・使用されます。設定は不要です。明示的な `adapter` 設定またはCLIの `--adapter ` で上書きできます。自動検出を無効にするには `--no-adapter` を使用してください。 diff --git a/docs/src/pages/ja/deploy.(index).mdx b/docs/src/pages/ja/deploy.(index).mdx index 0eb49092..f8417f90 100644 --- a/docs/src/pages/ja/deploy.(index).mdx +++ b/docs/src/pages/ja/deploy.(index).mdx @@ -1,9 +1,13 @@ # デプロイ +import AdapterGrid from "../../components/AdapterGrid.jsx"; + アプリが完成したら、それをウェブ上にデプロイできます。フレームワークには、アプリをさまざまなプラットフォームにデプロイするためのアダプタが用意されています。 また、フレームワークの組み込みサーバを使用すれば、Node.jsを使って自分のサーバにアプリをデプロイすることもできます。 [adapters](/deploy/adapters) を使用して、さまざまなデプロイ環境向けにアプリを構成する方法を学びます。 -また、利用可能なビルトインアダプタを使用して、アプリをさまざまなプラットフォームにデプロイする方法を学ぶこともできます。フレームワークでは [Vercel](/deploy/vercel)、[Netlify](/deploy/netlify)、[Cloudflare](/deploy/cloudflare)、[Bun](/deploy/bun)、[Deno](/deploy/deno) のアダプタを提供しています。 +## 利用可能なアダプタ + + デプロイメント アダプタの実装方法については [Adapter API](/deploy/api) セクションを参照してください。 From d54e88f32594a86b0546b910c3aa11d8e24894f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Sun, 1 Mar 2026 12:42:34 +0100 Subject: [PATCH 4/4] fix: adapter out dir --- docs/src/pages/en/(pages)/deploy/api.mdx | 21 +++++++-- packages/react-server/adapters/README.md | 46 ++++++++++++++----- .../react-server/adapters/azure-swa/index.mjs | 9 +++- .../react-server/adapters/azure/index.mjs | 11 +++-- packages/react-server/adapters/bun/index.mjs | 6 +-- .../adapters/cloudflare/index.mjs | 6 +-- .../adapters/cloudflare/worker/edge.mjs | 4 +- packages/react-server/adapters/core.mjs | 3 +- packages/react-server/adapters/deno/index.mjs | 6 +-- .../react-server/adapters/netlify/index.mjs | 23 ++++++---- 10 files changed, 96 insertions(+), 39 deletions(-) diff --git a/docs/src/pages/en/(pages)/deploy/api.mdx b/docs/src/pages/en/(pages)/deploy/api.mdx index 29a8efa9..20785a8e 100644 --- a/docs/src/pages/en/(pages)/deploy/api.mdx +++ b/docs/src/pages/en/(pages)/deploy/api.mdx @@ -59,6 +59,14 @@ You need to pass adapter properties to the `createAdapter` function to configure `deploy`: The deployment command and arguments. This is optional. When provided, the adapter will show what command the developer needs to run to deploy the application after it has been built. If the `--deploy` flag is provided during the build, the adapter will run this command. The `deploy` property can also be a function that will be called with the adapter options, CLI options and the handler result. This is useful if you need to customize the deployment command based on the adapter options or the handler result. If you don't provide a result with `command` and `args`, the default deployment handling spawning the command will be skipped. This is useful if you want to implement a custom deployment workflow in the adapter. +The deploy descriptor supports the following properties: + +- [ ] `command`: The CLI command to run. +- [ ] `args`: The command arguments. +- [ ] `cwd`: The working directory for the command. Defaults to the project root. +- [ ] `message`: Help text shown to the user when `--deploy` is not used. +- [ ] `afterDeploy`: A callback invoked after successful deployment (e.g., to print the deployment URL). + ```js export const adapter = createAdapter({ // ... @@ -73,6 +81,10 @@ export const adapter = createAdapter({ return { command: "vercel", args: ["deploy", "--prebuilt"], + cwd: outDir, + afterDeploy: () => { + console.log("Deployment complete!"); + }, }; }, }); @@ -88,11 +100,13 @@ The adapter handler function will receive the following properties: - [ ] `files`: The files object contains the static files, assets, client files, public files, server files and the dependencies. - [ ] `copy`: The copy object contains the functions to copy the files to the output directory. - [ ] `config`: The configuration object contains the configuration of the application. -- [ ] `reactServerDir`: The path to the directory where the build output is located. -- [ ] `reactServerOutDir`: The directory name where the build output is located. +- [ ] `reactServerDir`: The absolute path to the directory where the build output is located. +- [ ] `reactServerOutDir`: The relative directory name where the build output is located (default `.react-server`, configurable via `outDir` in config). - [ ] `root`: The entry point of the application. - [ ] `options`: The options object contains the options passed from the CLI. +> **Important:** When referencing server files in generated code (e.g., import paths), always use `reactServerOutDir` instead of hardcoding `.react-server`. This ensures your adapter works when users customize the build output directory. + The `files` object contains the following functions: - [ ] `static`: The function to get the static files. @@ -215,10 +229,11 @@ const dependencies = await getDependencies(adapterFiles, reactServerDir); ### spawnCommand -Spawns a command in the current working directory. +Spawns a command. Accepts an optional options object with `cwd` to set the working directory. ```js await spawnCommand("vercel", ["deploy", "--prebuilt"]); +await spawnCommand("func", ["azure", "functionapp", "publish", appName], { cwd: outDir }); ``` ### deepMerge diff --git a/packages/react-server/adapters/README.md b/packages/react-server/adapters/README.md index 60565c74..ed9917b1 100644 --- a/packages/react-server/adapters/README.md +++ b/packages/react-server/adapters/README.md @@ -97,7 +97,7 @@ Returns an async function `(adapterOptions, root, options) => void` that: 1. Clears `outDir` 2. If `outStaticDir` is set, auto-copies: static, assets, client, public files -3. If `outServerDir` is set, auto-copies: server files to `/.react-server/` +3. If `outServerDir` is set, auto-copies: server files to `//` 4. Calls your `handler` callback 5. Handles deployment (runs or prints deploy command) @@ -111,8 +111,8 @@ The `handler` receives an object with: | `files` | Lazy file getters (see below) | | `copy` | File copy helpers (see below) | | `config` | Resolved react-server config | -| `reactServerDir` | Absolute path to `.react-server/` | -| `reactServerOutDir` | Relative outDir (usually `.react-server`) | +| `reactServerDir` | Absolute path to the react-server build output directory | +| `reactServerOutDir` | Relative outDir name (default `".react-server"`, configurable via `outDir` in react-server config) | | `root` | Application root | | `options` | Build CLI options (includes `sourcemap`, `minify`, `deploy`, etc.) | @@ -146,20 +146,44 @@ Each method copies the corresponding `files.*` set. Accepts optional `out` overr | `copy.assets(out?)` | `outStaticDir` | CSS and other Vite assets | | `copy.client(out?)` | `outStaticDir` | Client component JS bundles | | `copy.public(out?)` | `outStaticDir` | User's public directory files | -| `copy.server(out?)` | `outServerDir` | Server MJS + manifests → `/.react-server/` | +| `copy.server(out?)` | `outServerDir` | Server MJS + manifests → `//` | | `copy.dependencies(out, files?)` | — | Traces & copies all Node.js deps via `@vercel/nft` | **Note:** If you set `outStaticDir` and `outServerDir`, the static/assets/client/public and server files are auto-copied before your handler runs. You only need to call `copy.*()` yourself if you need custom output destinations or if you didn't set those properties. ### `deploy` -Either a static object or a function. If `-—deploy` CLI flag is passed, the command is executed; otherwise it's printed for manual use. +Either a static object or an async function. If `--deploy` CLI flag is passed, the command is executed; otherwise it's printed for manual use. + +The deploy descriptor supports the following properties: + +| Property | Type | Description | +|----------|------|-------------| +| `command` | `string` | The CLI command to run (e.g., `"func"`, `"swa"`, `"wrangler"`) | +| `args` | `string[]` | Arguments for the command | +| `cwd` | `string?` | Working directory for the command (defaults to project root) | +| `message` | `string?` | Help text shown when `--deploy` is not used | +| `afterDeploy` | `() => void \| Promise` | Callback invoked after successful deployment (e.g., to print the deployment URL) | ```js +// Static descriptor deploy: { command: "bun", args: [".bun/start.mjs"], } + +// Async function returning a descriptor +deploy: async ({ adapterOptions, options, handlerResult }) => { + // Optionally provision resources, resolve app name, etc. + return { + command: "func", + args: ["azure", "functionapp", "publish", appName, "--javascript"], + cwd: outDir, + afterDeploy: () => { + banner(`deployed to https://${appName}.azurewebsites.net`, { emoji: "🌐" }); + }, + }; +} ``` ## Core Utility Functions @@ -180,7 +204,7 @@ Imported from `@lazarv/react-server/adapters/core`: | `clearDirectory(dir)` | `rm -rf` a directory | | `getFiles(pattern, srcDir)` | Glob files | | `getDependencies(files, dir)` | Trace Node.js dependencies with `@vercel/nft` | -| `spawnCommand(cmd, args)` | Spawn a child process (for deploy commands) | +| `spawnCommand(cmd, args, options?)` | Spawn a child process with optional `{ cwd }` (for deploy commands) | | `getConfig()` | Get resolved react-server config | | `getPublicDir()` | Get absolute path to public directory | @@ -277,11 +301,11 @@ The edge runtime does **not** serve static files. Your entry must handle this: - **Runtime**: Edge (Cloudflare Workers) - **Entry**: `worker/edge.mjs` — uses `env.ASSETS.fetch()` for static files -- **Output**: `.cloudflare/static/` + `.cloudflare/worker/.react-server/` +- **Output**: `.cloudflare/static/` + `.cloudflare/worker//` - **Config**: Generates `wrangler.toml`, merges with `react-server.wrangler.toml` - **Static files**: Handled by Cloudflare's `ASSETS` binding - **Deploy**: `wrangler deploy` -- **Notes**: Sets `base_dir` to `.cloudflare/worker/.react-server` so `outDir: "."` works +- **Notes**: Sets `base_dir` to `.cloudflare/worker/` so `outDir: "."` works ### Netlify (`adapters/netlify/`) @@ -307,7 +331,7 @@ The edge runtime does **not** serve static files. Your entry must handle this: - **Runtime**: Edge (Bun.serve with fetch handler) - **Entry**: `server/entry.mjs` — exports `handler`, `createContext`, `port`, `hostname` (minimal; no static file serving) -- **Output**: `.bun/static/` + `.bun/server/.react-server/` +- **Output**: `.bun/static/` + `.bun/server//` - **Config**: Generates `start.mjs` with build-time static route map + `package.json` - **Static files**: Build-time route map in generated `start.mjs` using `Bun.serve({ static })` for zero-copy serving - **Deploy**: `bun .bun/start.mjs` @@ -317,7 +341,7 @@ The edge runtime does **not** serve static files. Your entry must handle this: - **Runtime**: Edge (Deno.serve with fetch handler) - **Entry**: `server/entry.mjs` — exports `handler`, `createContext`, `port`, `hostname` (minimal; no static file serving) -- **Output**: `.deno/static/` + `.deno/server/.react-server/` +- **Output**: `.deno/static/` + `.deno/server//` - **Config**: Generates `start.mjs` with build-time static route map + `deno.json` - **Static files**: Build-time route map in generated `start.mjs` using `Deno.readFile()` for static serving - **Deploy**: `deno run --allow-net --allow-read --allow-env --allow-sys .deno/start.mjs` @@ -327,7 +351,7 @@ The edge runtime does **not** serve static files. Your entry must handle this: - **Runtime**: Edge (Azure Functions v4 programming model with streaming) - **Entry**: `functions/entry.mjs` — edge entry using `@lazarv/react-server/edge` with `createContext` -- **Output**: `.azure/static/` + `.azure/server/.react-server/` + `.azure/src/functions/server.mjs` +- **Output**: `.azure/static/` + `.azure/server//` + `.azure/src/functions/server.mjs` - **Config**: Generates `host.json`, `package.json` (with `@azure/functions` dependency), `local.settings.json`, and `main.bicep` (IaC template) - **Static files**: Build-time route map in generated `src/functions/server.mjs` — serves static files from disk via `readFileSync()` - **Deploy**: `func azure functionapp publish --javascript` diff --git a/packages/react-server/adapters/azure-swa/index.mjs b/packages/react-server/adapters/azure-swa/index.mjs index 9638f9f5..caac9068 100644 --- a/packages/react-server/adapters/azure-swa/index.mjs +++ b/packages/react-server/adapters/azure-swa/index.mjs @@ -31,7 +31,12 @@ export const adapter = createAdapter({ name: "Azure Static Web Apps", outDir, outStaticDir, - handler: async function ({ adapterOptions, copy, options }) { + handler: async function ({ + adapterOptions, + copy, + options, + reactServerOutDir, + }) { banner("building Azure Functions", { emoji: "⚡" }); const outServerDir = join(outDir, "functions/server"); @@ -48,7 +53,7 @@ export const adapter = createAdapter({ const entryFile = join(outServerDir, "index.mjs"); writeFileSync( entryFile, - `import handler from "./.react-server/server/edge.mjs"; + `import handler from "./${reactServerOutDir}/server/edge.mjs"; export default async function (context, req) { try { diff --git a/packages/react-server/adapters/azure/index.mjs b/packages/react-server/adapters/azure/index.mjs index a807d934..26886aa5 100644 --- a/packages/react-server/adapters/azure/index.mjs +++ b/packages/react-server/adapters/azure/index.mjs @@ -256,7 +256,12 @@ export const adapter = createAdapter({ outDir, outStaticDir, outServerDir, - handler: async function ({ adapterOptions, files, options }) { + handler: async function ({ + adapterOptions, + files, + options, + reactServerOutDir, + }) { // Collect all static file paths for the route map banner("generating static file manifest", { emoji: "🗺️" }); const [staticFiles, assetFiles, clientFiles, publicFiles] = @@ -304,7 +309,7 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const staticDir = join(__dirname, "../../static"); -const serverDir = join(__dirname, "../../server/.react-server"); +const serverDir = join(__dirname, "../../server/${reactServerOutDir}"); const MIME_TYPES = { ".html": "text/html; charset=utf-8", @@ -342,7 +347,7 @@ const STATIC_FILES = ${staticMapJson}; const CACHE_IMMUTABLE = "public, max-age=31536000, immutable"; process.chdir(serverDir); -const edgeHandler = (await import("../../server/.react-server/server/edge.mjs")).default; +const edgeHandler = (await import("../../server/${reactServerOutDir}/server/edge.mjs")).default; app.http("server", { methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], diff --git a/packages/react-server/adapters/bun/index.mjs b/packages/react-server/adapters/bun/index.mjs index 39a0abb0..fff02222 100644 --- a/packages/react-server/adapters/bun/index.mjs +++ b/packages/react-server/adapters/bun/index.mjs @@ -34,7 +34,7 @@ export const adapter = createAdapter({ outDir, outStaticDir, outServerDir, - handler: async function ({ adapterOptions, files }) { + handler: async function ({ adapterOptions, files, reactServerOutDir }) { // Collect all static file paths for the route map banner("generating static route map", { emoji: "🗺️" }); const [staticFiles, assetFiles, clientFiles, publicFiles] = @@ -96,8 +96,8 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const staticDir = join(__dirname, "static"); -process.chdir(join(__dirname, "server/.react-server")); -const { handler, createContext, port, hostname } = await import("./server/.react-server/server/edge.mjs"); +process.chdir(join(__dirname, "server/${reactServerOutDir}")); +const { handler, createContext, port, hostname } = await import("./server/${reactServerOutDir}/server/edge.mjs"); let origin; diff --git a/packages/react-server/adapters/cloudflare/index.mjs b/packages/react-server/adapters/cloudflare/index.mjs index bdb5a090..b15d0afb 100644 --- a/packages/react-server/adapters/cloudflare/index.mjs +++ b/packages/react-server/adapters/cloudflare/index.mjs @@ -39,7 +39,7 @@ export const adapter = createAdapter({ outDir, outStaticDir, outServerDir, - handler: async function ({ adapterOptions, options }) { + handler: async function ({ adapterOptions, options, reactServerOutDir }) { // Create wrangler.toml configuration banner("creating Cloudflare Worker configuration", { emoji: "⚙️" }); @@ -62,7 +62,7 @@ export const adapter = createAdapter({ const wranglerConfig = { name: appName ?? "react-server-app", - main: ".cloudflare/worker/.react-server/server/edge.mjs", + main: `.cloudflare/worker/${reactServerOutDir}/server/edge.mjs`, compatibility_date: adapterOptions?.compatibilityDate ?? new Date().toISOString().split("T")[0], @@ -71,7 +71,7 @@ export const adapter = createAdapter({ ...(adapterOptions?.compatibilityFlags ?? []), ], find_additional_modules: true, - base_dir: ".cloudflare/worker/.react-server", + base_dir: `.cloudflare/worker/${reactServerOutDir}`, rules: [ { type: "ESModule", diff --git a/packages/react-server/adapters/cloudflare/worker/edge.mjs b/packages/react-server/adapters/cloudflare/worker/edge.mjs index fe2be800..db904551 100644 --- a/packages/react-server/adapters/cloudflare/worker/edge.mjs +++ b/packages/react-server/adapters/cloudflare/worker/edge.mjs @@ -19,8 +19,8 @@ export default { origin: env.ORIGIN || `${new URL(request.url).protocol}//${new URL(request.url).host}`, - // Use "." because we're already inside the .react-server directory structure - // (base_dir in wrangler.toml points to .cloudflare/worker/.react-server) + // Use "." because we're already inside the react-server output directory structure + // (base_dir in wrangler.toml points to .cloudflare/worker/) outDir: ".", }); } diff --git a/packages/react-server/adapters/core.mjs b/packages/react-server/adapters/core.mjs index fed81ce2..a6393b09 100644 --- a/packages/react-server/adapters/core.mjs +++ b/packages/react-server/adapters/core.mjs @@ -726,7 +726,7 @@ export function createAdapter({ "copying server files", await files.server(), reactServerDir, - join(out ?? outServerDir, ".react-server"), + join(out ?? outServerDir, reactServerOutDir), reactServerOutDir, "🖥️" ), @@ -830,6 +830,7 @@ export function createAdapter({ if (afterDeploy) { await afterDeploy(); } + clearProgress(); } else { const deployCmd = `${command} ${args.join(" ")}`; const deployLabel = `🚀 Deploy to ${name} using:`; diff --git a/packages/react-server/adapters/deno/index.mjs b/packages/react-server/adapters/deno/index.mjs index 01753734..367239bb 100644 --- a/packages/react-server/adapters/deno/index.mjs +++ b/packages/react-server/adapters/deno/index.mjs @@ -69,7 +69,7 @@ export const adapter = createAdapter({ outDir, outStaticDir, outServerDir, - handler: async function ({ adapterOptions, files }) { + handler: async function ({ adapterOptions, files, reactServerOutDir }) { // Collect all static file paths for the route map banner("generating static route map", { emoji: "🗺️" }); const [staticFiles, assetFiles, clientFiles, publicFiles] = @@ -126,8 +126,8 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const staticDir = join(__dirname, "static"); -process.chdir(join(__dirname, "server/.react-server")); -const { handler, createContext, port, hostname } = await import("./server/.react-server/server/edge.mjs"); +process.chdir(join(__dirname, "server/${reactServerOutDir}")); +const { handler, createContext, port, hostname } = await import("./server/${reactServerOutDir}/server/edge.mjs"); const MIME_TYPES = ${JSON.stringify(MIME_TYPES, null, 2)}; diff --git a/packages/react-server/adapters/netlify/index.mjs b/packages/react-server/adapters/netlify/index.mjs index 01867a6f..b0b6c987 100644 --- a/packages/react-server/adapters/netlify/index.mjs +++ b/packages/react-server/adapters/netlify/index.mjs @@ -57,7 +57,14 @@ export const adapter = createAdapter({ outDir, outStaticDir, // outServerDir is computed dynamically based on edgeFunctions option or --edge CLI flag - handler: async function ({ adapterOptions, copy, files, options }) { + handler: async function ({ + adapterOptions, + copy, + files, + options, + reactServerDir, + reactServerOutDir, + }) { // Check the preserved flag (set by buildOptions) or adapter config const isEdge = Boolean( options?.netlifyEdgeFunctions || adapterOptions?.edgeFunctions @@ -72,20 +79,20 @@ export const adapter = createAdapter({ // Copy server files to the computed output directory if (isEdge) { - await mkdir(join(outServerDir, ".react-server/server"), { + await mkdir(join(outServerDir, `${reactServerOutDir}/server`), { recursive: true, }); await copyFile( - join(cwd, ".react-server/server/edge.mjs"), - join(outServerDir, ".react-server/server/edge.mjs") + join(reactServerDir, "server/edge.mjs"), + join(outServerDir, `${reactServerOutDir}/server/edge.mjs`) ); // Copy source map file for edge.mjs if sourcemaps are enabled if (options.sourcemap) { - const edgeMapPath = join(cwd, ".react-server/server/edge.mjs.map"); + const edgeMapPath = join(reactServerDir, "server/edge.mjs.map"); if (existsSync(edgeMapPath)) { await copyFile( edgeMapPath, - join(outServerDir, ".react-server/server/edge.mjs.map") + join(outServerDir, `${reactServerOutDir}/server/edge.mjs.map`) ); } } @@ -103,7 +110,7 @@ export const adapter = createAdapter({ const entryFile = join(outServerDir, "server.mjs"); writeFileSync( entryFile, - `export { default } from "./.react-server/server/edge.mjs"; + `export { default } from "./${reactServerOutDir}/server/edge.mjs"; export const config = { path: "/*", @@ -125,7 +132,7 @@ export const config = { const entryFile = join(outServerDir, "index.mjs"); writeFileSync( entryFile, - `export { default } from "./.react-server/server/edge.mjs"; + `export { default } from "./${reactServerOutDir}/server/edge.mjs"; export const config = { path: "/*",