diff --git a/docs/content/2.deploy/providers/azure.md b/docs/content/2.deploy/providers/azure.md index f97c8b2a68..cb478aaf49 100644 --- a/docs/content/2.deploy/providers/azure.md +++ b/docs/content/2.deploy/providers/azure.md @@ -25,6 +25,25 @@ NITRO_PRESET=azure yarn build npx @azure/static-web-apps-cli start .output/public --api-location .output/server ``` +### Configuration + +Azure Static Web Apps are [configured](https://learn.microsoft.com/en-us/azure/static-web-apps/configuration) using the `staticwebapp.config.json` file. + +Nitro automatically generates this configuration file whenever the application is built with the `azure` preset. + +Nitro will automatically add the following properties based on the following criteria: +| Property | Criteria | Default | +| --- | --- | --- | +| **[platform.apiRuntime](https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#platform)** | Will automatically set to `node:16` or `node:14` depending on your package configuration. | `node:16` | +| **[navigationFallback.rewrite](https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#fallback-routes)** | Is always `/api/server` | `/api/server` | +| **[routes](https://learn.microsoft.com/en-us/azure/static-web-apps/configuration#routes)** | All prerendered routes are added. Additionally, if you do not have an `index.html` file an empty one is created for you for compatibility purposes and also requests to `/index.html` are redirected to the root directory which is handled by `/api/server`. | `[]` | + +### Custom Configuration + +You can alter the Nitro generated configuration using `azure.config` option. + +Custom routes will be added and matched first. In the case of a conflict (determined if an object has the same route property), custom routes will override generated ones. + ### Deploy from CI/CD via GitHub Actions When you link your GitHub repository to Azure Static Web Apps, a workflow file is added to the repository. diff --git a/src/presets/azure.ts b/src/presets/azure.ts index a427998924..24696bf77f 100644 --- a/src/presets/azure.ts +++ b/src/presets/azure.ts @@ -41,13 +41,17 @@ async function writeRoutes(nitro: Nitro) { } } + // Merge custom config into the generated config const config = { + ...nitro.options.azure?.config, + routes: [], // Overwrite routes for now, we will add existing routes after generating routes platform: { apiRuntime: `node:${nodeVersion}`, + ...nitro.options.azure?.config?.platform, }, - routes: [], navigationFallback: { rewrite: "/api/server", + ...nitro.options.azure?.config?.navigationFallback, }, }; @@ -99,6 +103,28 @@ async function writeRoutes(nitro: Nitro) { }); } + // Prepend custom routes to the beginning of the routes array and override if they exist + if ( + nitro.options.azure?.config && + "routes" in nitro.options.azure.config && + Array.isArray(nitro.options.azure.config.routes) + ) { + // We iterate through the reverse so the order in the custom config is persisted + for (const customRoute of nitro.options.azure.config.routes.reverse()) { + const existingRouteMatchIndex = config.routes.findIndex( + (value) => value.route === customRoute.route + ); + + if (existingRouteMatchIndex === -1) { + // If we don't find a match, put the customRoute at the beginning of the array + config.routes.unshift(customRoute); + } else { + // Otherwise override the existing route with our customRoute + config.routes[existingRouteMatchIndex] = customRoute; + } + } + } + const functionDefinition = { entryPoint: "handle", bindings: [ diff --git a/src/types/presets.ts b/src/types/presets.ts index e47116bc73..fafccee23c 100644 --- a/src/types/presets.ts +++ b/src/types/presets.ts @@ -122,6 +122,20 @@ export interface VercelServerlessFunctionConfig { [key: string]: unknown; } +interface AzureOptions { + config?: { + platform?: { + apiRuntime?: string; + [key: string]: unknown; + }; + navigationFallback?: { + rewrite?: string; + [key: string]: unknown; + }; + [key: string]: unknown; + }; +} + export interface PresetOptions { vercel: { config: VercelBuildConfigV3; @@ -166,4 +180,5 @@ export interface PresetOptions { defaultRoutes?: boolean; }; }; + azure: AzureOptions; } diff --git a/test/presets/azure.test.ts b/test/presets/azure.test.ts new file mode 100644 index 0000000000..46f090d2e3 --- /dev/null +++ b/test/presets/azure.test.ts @@ -0,0 +1,109 @@ +import { promises as fsp } from "node:fs"; +import { resolve } from "pathe"; +import { describe, it, expect } from "vitest"; +import { fixtureDir, setupTest } from "../tests"; + +describe( + "nitro:preset:azure", + async () => { + const customConfig = { + routes: [ + { + route: "/admin", + allowedRoles: ["authenticated"], + }, + { + route: "/logout", + redirect: "/.auth/logout", + }, + { + route: "/index.html", + redirect: "/overridden-index", + }, + { + route: "/", + rewrite: "/api/server/overridden", + }, + ], + responseOverrides: { + 401: { + statusCode: 302, + redirect: "/.auth/login/aad", + }, + }, + networking: { + allowedIpRanges: ["10.0.0.0/24", "100.0.0.0/32", "192.168.100.0/22"], + }, + platform: { + apiRuntime: "custom-runtime", + }, + }; + + const ctx = await setupTest("azure", { + config: { + azure: { + config: customConfig, + }, + }, + }); + + const config = await fsp + .readFile(resolve(ctx.rootDir, "staticwebapp.config.json"), "utf8") + .then((r) => JSON.parse(r)); + + it("generated the correct config", () => { + expect(config).toMatchInlineSnapshot(` + { + "navigationFallback": { + "rewrite": "/api/server", + }, + "networking": { + "allowedIpRanges": [ + "10.0.0.0/24", + "100.0.0.0/32", + "192.168.100.0/22", + ], + }, + "platform": { + "apiRuntime": "custom-runtime", + }, + "responseOverrides": { + "401": { + "redirect": "/.auth/login/aad", + "statusCode": 302, + }, + }, + "routes": [ + { + "allowedRoles": [ + "authenticated", + ], + "route": "/admin", + }, + { + "redirect": "/.auth/logout", + "route": "/logout", + }, + { + "rewrite": "/api/hey/index.html", + "route": "/api/hey", + }, + { + "rewrite": "/prerender/index.html", + "route": "/prerender", + }, + { + "redirect": "/overridden-index", + "route": "/index.html", + }, + { + "rewrite": "/api/server/overridden", + "route": "/", + }, + ], + } + `); + }); + }, + { timeout: 10_000 } +); diff --git a/test/tests.ts b/test/tests.ts index ab1d37ab7a..8780cdf1b0 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -2,12 +2,12 @@ import { tmpdir } from "node:os"; import { promises as fsp } from "node:fs"; import { join, resolve } from "pathe"; import { listen, Listener } from "listhen"; -import fse from "fs-extra"; import destr from "destr"; import { fetch, FetchOptions } from "ofetch"; import { expect, it, afterAll, beforeAll, describe } from "vitest"; import { fileURLToPath } from "mlly"; import { joinURL } from "ufo"; +import { defu } from "defu"; import * as _nitro from "../src"; import type { Nitro } from "../src"; @@ -37,8 +37,14 @@ export const describeIf = (condition, title, factory) => it.skip("skipped", () => {}); }); -export async function setupTest(preset: string) { - const fixtureDir = fileURLToPath(new URL("fixture", import.meta.url).href); +export const fixtureDir = fileURLToPath( + new URL("fixture", import.meta.url).href +); + +export async function setupTest( + preset: string, + opts: { config?: _nitro.NitroConfig } = {} +) { const presetTempDir = resolve( process.env.NITRO_TEST_TMP_DIR || join(tmpdir(), "nitro-tests"), preset @@ -69,32 +75,34 @@ export async function setupTest(preset: string) { process.env[name] = value; } - const nitro = (ctx.nitro = await createNitro({ - preset: ctx.preset, - dev: ctx.isDev, - rootDir: ctx.rootDir, - runtimeConfig: { - nitro: { - envPrefix: "CUSTOM_", + const nitro = (ctx.nitro = await createNitro( + defu(opts.config, { + preset: ctx.preset, + dev: ctx.isDev, + rootDir: ctx.rootDir, + runtimeConfig: { + nitro: { + envPrefix: "CUSTOM_", + }, + hello: "", + helloThere: "", }, - hello: "", - helloThere: "", - }, - buildDir: resolve(fixtureDir, presetTempDir, ".nitro"), - serveStatic: - preset !== "cloudflare" && - preset !== "cloudflare-module" && - preset !== "cloudflare-pages" && - preset !== "vercel-edge" && - !ctx.isDev, - output: { - dir: ctx.outDir, - }, - timing: - preset !== "cloudflare" && - preset !== "cloudflare-pages" && - preset !== "vercel-edge", - })); + buildDir: resolve(fixtureDir, presetTempDir, ".nitro"), + serveStatic: + preset !== "cloudflare" && + preset !== "cloudflare-module" && + preset !== "cloudflare-pages" && + preset !== "vercel-edge" && + !ctx.isDev, + output: { + dir: ctx.outDir, + }, + timing: + preset !== "cloudflare" && + preset !== "cloudflare-pages" && + preset !== "vercel-edge", + }) + )); if (ctx.isDev) { // Setup development server