diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index 4f6d3158e3..731ba64dc6 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -2,7 +2,7 @@ import fsp from "node:fs/promises"; import { constants } from "node:fs"; import { defu } from "defu"; import { writeFile } from "../_utils/fs.ts"; -import type { Nitro, NitroRouteRules, ProxyRuleOptions } from "nitro/types"; +import type { Nitro, NitroRouteRules, PrerenderRoute, ProxyRuleOptions } from "nitro/types"; import { basename, dirname, relative, resolve } from "pathe"; import { Router } from "../../routing.ts"; import { joinURL, withLeadingSlash, withoutLeadingSlash } from "ufo"; @@ -37,6 +37,12 @@ const ISR_SUFFIX = "-isr"; // Avoid using . as it can conflict with routing const SAFE_FS_CHAR_RE = /[^a-zA-Z0-9_.[\]/]/g; +// Vercel serves `/index.html` (and extensionless `/index`) at `` +// using built-in directory indexes. +const INDEX_FILE_RE = /(^|\/)index(\.html)?$/; + +const SURROUNDING_SLASH_RE = /^\/+|\/+$/g; + function getSystemNodeVersion() { const systemNodeVersion = Number.parseInt(process.versions.node.split(".")[0]); @@ -229,17 +235,7 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { name: nitro.options.framework.name, version: nitro.options.framework.version, }, - overrides: { - // Nitro static prerendered route overrides - ...Object.fromEntries( - (nitro._prerenderedRoutes?.filter((r) => r.fileName !== r.route) || []).map( - ({ route, fileName }) => [ - withoutLeadingSlash(fileName), - { path: route.replace(/^\//, "") }, - ] - ) - ), - }, + overrides: getPrerenderOverrides(nitro._prerenderedRoutes), routes: [ // Redirect and header rules (excluding paths handled as CDN proxy rewrites) ...rules @@ -390,6 +386,38 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { return config; } +/** + * Map prerendered files to the route they should be served from. + * + * Paths are always slash-free: Vercel strips slashes when matching, so a path + * that keeps a trailing slash matches nothing at all (#4392), and the root + * route has to map to an empty path (ufo's slash helpers cannot produce one). + * + * Files that Vercel already serves at the route using its built-in directory + * indexes are skipped. + */ +export function getPrerenderOverrides(prerenderedRoutes: PrerenderRoute[] = []) { + const overrides: Record = {}; + + for (const { route, fileName } of prerenderedRoutes) { + if (!fileName) { + continue; + } + const file = withoutLeadingSlash(fileName); + const path = route.replace(SURROUNDING_SLASH_RE, ""); + // Skip when Vercel already serves the file at `path`: either via its + // built-in directory index (`/index.*` at ``), or because the + // file already lives there. Re-keying a file onto its own path would + // delete it, since Vercel drops the original entry. + if (file === path || file.replace(INDEX_FILE_RE, "") === path) { + continue; + } + overrides[file] = { path }; + } + + return overrides; +} + export function deprecateSWR(nitro: Nitro) { if (nitro.options.future.nativeSWR) { return; @@ -500,13 +528,23 @@ type ObservabilityRoute = { dest: string; // function name }; -function getObservabilityRoutes(nitro: Nitro): ObservabilityRoute[] { +export function getObservabilityRoutes(nitro: Nitro): ObservabilityRoute[] { const compatDate = nitro.options.compatibilityDate.vercel || nitro.options.compatibilityDate.default; if (compatDate < "2025-07-15") { return []; } + // Vercel resolves functions and static files from a single path to output + // map that functions are added to last, so a function at the path of a + // prerendered file hides that file and serves the route with SSR on every + // request (#4242). + const prerenderedPaths = new Set( + (nitro._prerenderedRoutes || []) + .filter((route) => route.fileName) + .map((route) => route.route.replace(SURROUNDING_SLASH_RE, "")) + ); + // Sort routes by how much specific they are const routePatterns = [ ...new Set([ @@ -515,7 +553,7 @@ function getObservabilityRoutes(nitro: Nitro): ObservabilityRoute[] { .filter((h) => !h.middleware && h.route) .map((h) => h.route!), ]), - ]; + ].filter((route) => !prerenderedPaths.has(route.replace(SURROUNDING_SLASH_RE, ""))); const staticRoutes: string[] = []; const dynamicRoutes: string[] = []; diff --git a/test/presets/fixtures/slash.ts b/test/presets/fixtures/slash.ts new file mode 100644 index 0000000000..2ec73828f3 --- /dev/null +++ b/test/presets/fixtures/slash.ts @@ -0,0 +1,6 @@ +import { defineHandler } from "nitro/h3"; + +export default defineHandler((event) => { + event.res.headers.set("content-type", "text/html"); + return "slash"; +}); diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index ae4d31e71a..3eead03302 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -21,7 +21,15 @@ describe("nitro:preset:vercel:web", async () => { route: "/_ws", handler: resolve(presetFixturesDir, "websocket.ts"), }, + { + route: "/slash", + handler: resolve(presetFixturesDir, "slash.ts"), + }, ], + prerender: { + // trailing slash on purpose (#4392) + routes: ["/slash/"], + }, vercel: { queues: { triggers: [ @@ -63,20 +71,7 @@ describe("nitro:preset:vercel:web", async () => { "name": "nitro", "version": "3.x", }, - "overrides": { - "_scalar/index.html": { - "path": "_scalar", - }, - "_swagger/index.html": { - "path": "_swagger", - }, - "api/hey/index.html": { - "path": "api/hey", - }, - "prerender/index.html": { - "path": "prerender", - }, - }, + "overrides": {}, "routes": [ { "headers": { @@ -260,14 +255,6 @@ describe("nitro:preset:vercel:web", async () => { "dest": "/raw", "src": "/raw", }, - { - "dest": "/prerender-custom.html", - "src": "/prerender-custom.html", - }, - { - "dest": "/prerender", - "src": "/prerender", - }, { "dest": "/node-compat", "src": "/node-compat", @@ -280,10 +267,6 @@ describe("nitro:preset:vercel:web", async () => { "dest": "/jsx", "src": "/jsx", }, - { - "dest": "/json-string", - "src": "/json-string", - }, { "dest": "/imports", "src": "/imports", @@ -360,14 +343,6 @@ describe("nitro:preset:vercel:web", async () => { "dest": "/api/kebab", "src": "/api/kebab", }, - { - "dest": "/api/hey", - "src": "/api/hey", - }, - { - "dest": "/api/hello", - "src": "/api/hello", - }, { "dest": "/api/headers", "src": "/api/headers", @@ -400,18 +375,6 @@ describe("nitro:preset:vercel:web", async () => { "dest": "/_vercel/cron", "src": "/_vercel/cron", }, - { - "dest": "/_swagger", - "src": "/_swagger", - }, - { - "dest": "/_scalar", - "src": "/_scalar", - }, - { - "dest": "/_openapi.json", - "src": "/_openapi.json", - }, { "dest": "/single-headers/[id]", "src": "/single-headers/(?[^/]+)", @@ -491,9 +454,6 @@ describe("nitro:preset:vercel:web", async () => { [ "functions/500.func (symlink)", "functions/__server.func", - "functions/_openapi.json.func (symlink)", - "functions/_scalar.func (symlink)", - "functions/_swagger.func (symlink)", "functions/_vercel", "functions/_ws.func (symlink)", "functions/api/cached.func (symlink)", @@ -501,7 +461,6 @@ describe("nitro:preset:vercel:web", async () => { "functions/api/echo.func", "functions/api/headers.func (symlink)", "functions/api/hello.func", - "functions/api/hey.func (symlink)", "functions/api/kebab.func (symlink)", "functions/api/meta/test.func (symlink)", "functions/api/methods/foo.get.func (symlink)", @@ -526,12 +485,9 @@ describe("nitro:preset:vercel:web", async () => { "functions/icon.png.func (symlink)", "functions/import-attributes.func (symlink)", "functions/imports.func (symlink)", - "functions/json-string.func (symlink)", "functions/jsx.func (symlink)", "functions/modules.func (symlink)", "functions/node-compat.func (symlink)", - "functions/prerender-custom.html.func (symlink)", - "functions/prerender.func (symlink)", "functions/raw.func (symlink)", "functions/replace.func (symlink)", "functions/route-group.func (symlink)", diff --git a/test/unit/vercel-observability-routes.test.ts b/test/unit/vercel-observability-routes.test.ts new file mode 100644 index 0000000000..d4aac5d991 --- /dev/null +++ b/test/unit/vercel-observability-routes.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import type { Nitro, NitroEventHandler, PrerenderRoute } from "nitro/types"; + +import { getObservabilityRoutes } from "../../src/presets/vercel/utils.ts"; + +function createNitroStub(opts: { + compatibilityDate?: string; + handlers?: NitroEventHandler[]; + ssrRoutes?: string[]; + prerenderedRoutes?: PrerenderRoute[]; +}): Nitro { + return { + scannedHandlers: opts.handlers || [], + _prerenderedRoutes: opts.prerenderedRoutes, + options: { + compatibilityDate: { default: opts.compatibilityDate || "2025-07-15" }, + handlers: [], + ssrRoutes: opts.ssrRoutes || [], + }, + } as unknown as Nitro; +} + +const dests = (nitro: Nitro) => getObservabilityRoutes(nitro).map((route) => route.dest); + +describe("getObservabilityRoutes", () => { + it("returns no routes before the observability compatibility date", () => { + expect( + getObservabilityRoutes( + createNitroStub({ + compatibilityDate: "2025-07-14", + handlers: [{ route: "/foo", handler: "foo.ts" }], + }) + ) + ).toEqual([]); + }); + + it("creates a route per handler, most specific first", () => { + expect( + getObservabilityRoutes( + createNitroStub({ + handlers: [ + { route: "/**", handler: "catch-all.ts" }, + { route: "/blog/:slug", handler: "blog.ts" }, + { route: "/foo", handler: "foo.ts" }, + { route: "/skipped", handler: "middleware.ts", middleware: true }, + ], + ssrRoutes: ["/"], + }) + ) + ).toEqual([ + { src: "/foo", dest: "foo" }, + { src: "/", dest: "index" }, + { src: "/blog/(?[^/]+)", dest: "blog/[slug]" }, + { src: "/(?:.*)", dest: "[...]" }, + ]); + }); + + // Vercel keeps functions and static files in a single path -> output map and + // lets the function win, so a function at the path of a prerendered file + // hides it and serves the route with SSR on every request (#4242) + it("skips routes served by a prerendered file", () => { + expect( + dests( + createNitroStub({ + handlers: [ + { route: "/prerendered", handler: "prerendered.ts" }, + { route: "/dynamic", handler: "dynamic.ts" }, + ], + prerenderedRoutes: [{ route: "/prerendered", fileName: "/prerendered/index.html" }], + }) + ) + ).toEqual(["dynamic"]); + }); + + it("skips prerendered ssrRoutes and explicit handlers alike", () => { + expect( + dests( + createNitroStub({ + ssrRoutes: ["/from-ssr-routes"], + handlers: [{ route: "/from-handlers", handler: "handler.ts" }], + prerenderedRoutes: [ + { route: "/from-ssr-routes", fileName: "/from-ssr-routes/index.html" }, + { route: "/from-handlers", fileName: "/from-handlers/index.html" }, + ], + }) + ) + ).toEqual([]); + }); + + // Vercel matches paths without surrounding slashes, so the route and the + // prerendered path have to be compared slash-free (#4392) + it("matches prerendered routes regardless of a trailing slash", () => { + expect( + dests( + createNitroStub({ + handlers: [{ route: "/slash", handler: "slash.ts" }], + prerenderedRoutes: [{ route: "/slash/", fileName: "/slash/index.html" }], + }) + ) + ).toEqual([]); + expect( + dests( + createNitroStub({ + handlers: [{ route: "/slash/", handler: "slash.ts" }], + prerenderedRoutes: [{ route: "/slash", fileName: "/slash.html" }], + }) + ) + ).toEqual([]); + }); + + // The root function is written to `index.func`, which shadows `index.html` + it("skips the root route when it is prerendered", () => { + expect( + dests( + createNitroStub({ + handlers: [{ route: "/", handler: "index.ts" }], + prerenderedRoutes: [{ route: "/", fileName: "/index.html" }], + }) + ) + ).toEqual([]); + }); + + // A dynamic function still has to serve every path that was not prerendered, + // and its output path never collides with a resolved prerendered path + it("keeps dynamic routes with prerendered leaves", () => { + expect( + dests( + createNitroStub({ + handlers: [ + { route: "/blog/:slug", handler: "blog.ts" }, + { route: "/docs/**", handler: "docs.ts" }, + ], + prerenderedRoutes: [ + { route: "/blog/post", fileName: "/blog/post/index.html" }, + { route: "/docs/nested/page", fileName: "/docs/nested/page/index.html" }, + ], + }) + ) + ).toEqual(["blog/[slug]", "docs/[...]"]); + }); + + it("keeps routes whose prerendered file was not written", () => { + expect( + dests( + createNitroStub({ + handlers: [{ route: "/failed", handler: "failed.ts" }], + prerenderedRoutes: [{ route: "/failed" }], + }) + ) + ).toEqual(["failed"]); + }); + + it("keeps routes without prerendering", () => { + expect(dests(createNitroStub({ handlers: [{ route: "/foo", handler: "foo.ts" }] }))).toEqual([ + "foo", + ]); + }); +}); diff --git a/test/unit/vercel-overrides.test.ts b/test/unit/vercel-overrides.test.ts new file mode 100644 index 0000000000..b42f0f8e1b --- /dev/null +++ b/test/unit/vercel-overrides.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { getPrerenderOverrides } from "../../src/presets/vercel/utils.ts"; + +describe("getPrerenderOverrides", () => { + it("returns no overrides without prerendered routes", () => { + expect(getPrerenderOverrides()).toEqual({}); + expect(getPrerenderOverrides([])).toEqual({}); + }); + + it("skips files Vercel serves as directory indexes", () => { + expect( + getPrerenderOverrides([ + { route: "/", fileName: "/index.html" }, + { route: "/noslash", fileName: "/noslash/index.html" }, + { route: "/nested/deep/", fileName: "/nested/deep/index.html" }, + // Extensionless index, e.g. a non-HTML route with a trailing slash + { route: "/api/data/", fileName: "/api/data/index" }, + ]) + ).toEqual({}); + }); + + // Keeping the trailing slash makes the path unmatchable (#4392) + it("does not emit a trailing-slash path", () => { + expect(getPrerenderOverrides([{ route: "/slash/", fileName: "/slash/index.html" }])).toEqual( + {} + ); + expect(getPrerenderOverrides([{ route: "/slash/", fileName: "/renamed/index.html" }])).toEqual({ + "renamed/index.html": { path: "slash" }, + }); + }); + + it("overrides files that are not served at their route", () => { + expect( + getPrerenderOverrides([ + // `autoSubfolderIndex: false` + { route: "/about", fileName: "/about.html" }, + { route: "/blog/post", fileName: "/blog/post.html" }, + // `fileName` rewritten in a `prerender:generate` hook + { route: "/bar/", fileName: "/renamed/index.html" }, + ]) + ).toEqual({ + "about.html": { path: "about" }, + "blog/post.html": { path: "blog/post" }, + "renamed/index.html": { path: "bar" }, + }); + }); + + // Vercel deletes the original entry when re-keying, so an override pointing a + // file at its own path would remove it from the deployment entirely + it("never points a file at its own path", () => { + const overrides = getPrerenderOverrides([ + { route: "/foo.html", fileName: "/foo.html" }, + { route: "/foo/index.html", fileName: "/foo/index.html" }, + { route: "/data.json", fileName: "/data.json" }, + // Route and file name normalize to the same key despite different spelling + { route: "/self/", fileName: "/self" }, + { route: "/about", fileName: "/about.html" }, + ]); + for (const [file, { path }] of Object.entries(overrides)) { + expect(path).not.toBe(file); + } + expect(overrides).toEqual({ "about.html": { path: "about" } }); + }); + + it("ignores routes without a fileName", () => { + expect(getPrerenderOverrides([{ route: "/skipped" }])).toEqual({}); + }); +});