diff --git a/src/presets/netlify/legacy/preset.ts b/src/presets/netlify/legacy/preset.ts index 9e5af03997..cdffee32f3 100644 --- a/src/presets/netlify/legacy/preset.ts +++ b/src/presets/netlify/legacy/preset.ts @@ -1,4 +1,4 @@ -import { existsSync, promises as fsp } from "node:fs"; +import { promises as fsp } from "node:fs"; import { defineNitroPreset } from "nitropack/kit"; import type { Nitro } from "nitropack/types"; import { dirname, join } from "pathe"; @@ -24,7 +24,7 @@ const netlify = defineNitroPreset( }, async compiled(nitro: Nitro) { await writeHeaders(nitro); - await writeRedirects(nitro); + await writeRedirects(nitro, "/.netlify/functions/server"); if (nitro.options.netlify) { const configPath = join( diff --git a/src/presets/netlify/legacy/utils.ts b/src/presets/netlify/legacy/utils.ts index 107f0c0d87..8ff99e0637 100644 --- a/src/presets/netlify/legacy/utils.ts +++ b/src/presets/netlify/legacy/utils.ts @@ -1,8 +1,27 @@ import { existsSync, promises as fsp } from "node:fs"; -import type { Nitro } from "nitropack/types"; +import type { Nitro, PublicAssetDir } from "nitropack/types"; import { join } from "pathe"; +import { joinURL } from "ufo"; -export async function writeRedirects(nitro: Nitro) { +export function generateCatchAllRedirects( + publicAssets: PublicAssetDir[], + catchAllPath?: string +): string { + if (!catchAllPath) return ""; + + return [ + // e.g.: /_nuxt/* /_nuxt/:splat 200 + // Because of Netlify CDN shadowing + // (https://docs.netlify.com/routing/redirects/rewrites-proxies/#shadowing), + // this config avoids function invocations for all static paths, even 404s. + ...getStaticPaths(publicAssets).map( + (path) => `${path} ${path.replace("/*", "/:splat")} 200` + ), + `/* ${catchAllPath} 200`, + ].join("\n"); +} + +export async function writeRedirects(nitro: Nitro, catchAllPath?: string) { const redirectsPath = join(nitro.options.output.publicDir, "_redirects"); const staticFallback = existsSync( join(nitro.options.output.publicDir, "404.html") @@ -11,7 +30,7 @@ export async function writeRedirects(nitro: Nitro) { : ""; let contents = nitro.options.static ? staticFallback - : "/* /.netlify/functions/server 200"; + : generateCatchAllRedirects(nitro.options.publicAssets, catchAllPath); const rules = Object.entries(nitro.options.routeRules).sort( (a, b) => a[0].split(/\/(?!\*)/).length - b[0].split(/\/(?!\*)/).length @@ -103,12 +122,20 @@ export async function writeHeaders(nitro: Nitro) { await fsp.writeFile(headersPath, contents); } +export function getStaticPaths(publicAssets: PublicAssetDir[]): string[] { + return publicAssets + .filter( + (dir) => dir.fallthrough !== true && dir.baseURL && dir.baseURL !== "/" + ) + .map((dir) => joinURL("/", dir.baseURL!, "*")); +} + export function deprecateSWR(nitro: Nitro) { if (nitro.options.future.nativeSWR) { return; } let hasLegacyOptions = false; - for (const [key, value] of Object.entries(nitro.options.routeRules)) { + for (const [_key, value] of Object.entries(nitro.options.routeRules)) { if (_hasProp(value, "isr")) { continue; } diff --git a/test/presets/netlify-legacy.test.ts b/test/presets/netlify-legacy.test.ts index bbd68c48f2..eae1890e0a 100644 --- a/test/presets/netlify-legacy.test.ts +++ b/test/presets/netlify-legacy.test.ts @@ -9,6 +9,12 @@ describe("nitro:preset:netlify-legacy", async () => { const ctx = await setupTest("netlify-legacy", { compatibilityDate: "2024-01-01", config: { + publicAssets: [ + { + dir: "dist/_nuxt", + baseURL: "_nuxt", + }, + ], output: { publicDir: resolve(getPresetTmpDir("netlify-legacy"), "dist"), }, @@ -70,6 +76,8 @@ describe("nitro:preset:netlify-legacy", async () => { /rules/isr-ttl/* /.netlify/builders/server 200 /rules/isr/* /.netlify/builders/server 200 /rules/dynamic /.netlify/functions/server 200 + /_nuxt/* /_nuxt/:splat 200 + /build/* /build/:splat 200 /* /.netlify/functions/server 200" `); }); diff --git a/test/unit/netlify-legacy.utils.test.ts b/test/unit/netlify-legacy.utils.test.ts new file mode 100644 index 0000000000..bf6499b58b --- /dev/null +++ b/test/unit/netlify-legacy.utils.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { generateCatchAllRedirects } from "../../src/presets/netlify/legacy/utils"; + +describe("generateCatchAllRedirects", () => { + it("returns empty string if `catchAllPath` is not defined", () => { + expect(generateCatchAllRedirects([], undefined)).toEqual(""); + }); + + it("includes a redirect from `/*` to `catchAllPath` if defined", () => { + expect(generateCatchAllRedirects([], "/catch-all")).toEqual( + "/* /catch-all 200" + ); + }); + + it("includes a splat redirect for each non-fallthrough non-root public asset path, BEFORE the catch-all", () => { + const publicAssets = [ + { + fallthrough: true, + baseURL: "with-fallthrough", + dir: "with-fallthrough-dir", + maxAge: 0, + }, + { + fallthrough: true, + dir: "with-fallthrough-no-baseURL-dir", + maxAge: 0, + }, + { + fallthrough: false, + dir: "no-fallthrough-no-baseURL-dir", + maxAge: 0, + }, + { + fallthrough: false, + dir: "no-fallthrough-root-baseURL-dir", + baseURL: "/", + maxAge: 0, + }, + { + baseURL: "with-default-fallthrough", + dir: "with-default-fallthrough-dir", + maxAge: 0, + }, + { + fallthrough: false, + baseURL: "nested/no-fallthrough", + dir: "nested/no-fallthrough-dir", + maxAge: 0, + }, + ]; + expect( + generateCatchAllRedirects(publicAssets, "/catch-all") + ).toMatchInlineSnapshot( + ` +"/with-default-fallthrough/* /with-default-fallthrough/:splat 200 +/nested/no-fallthrough/* /nested/no-fallthrough/:splat 200 +/* /catch-all 200" + `.trim() + ); + }); +});