From d30e930a29116e13ad28b428301a49f53089984b Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Thu, 30 Jul 2026 12:47:37 +0100 Subject: [PATCH] fix: do not generate observability functions with fully prerendered routes --- src/presets/vercel/utils.ts | 14 +- test/presets/vercel.test.ts | 44 ----- test/unit/vercel-observability-routes.test.ts | 158 ++++++++++++++++++ 3 files changed, 170 insertions(+), 46 deletions(-) create mode 100644 test/unit/vercel-observability-routes.test.ts diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index 300ad217a8..cd983c9bb2 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -542,13 +542,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([ @@ -557,7 +567,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/vercel.test.ts b/test/presets/vercel.test.ts index baae1668f4..06c2cd4a17 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -255,10 +255,6 @@ describe("nitro:preset:vercel:web", async () => { "dest": "/static-flags", "src": "/static-flags", }, - { - "dest": "/slash", - "src": "/slash", - }, { "dest": "/route-group", "src": "/route-group", @@ -271,14 +267,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", @@ -291,10 +279,6 @@ describe("nitro:preset:vercel:web", async () => { "dest": "/jsx", "src": "/jsx", }, - { - "dest": "/json-string", - "src": "/json-string", - }, { "dest": "/imports", "src": "/imports", @@ -371,14 +355,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", @@ -411,18 +387,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/(?[^/]+)", @@ -498,9 +462,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)", @@ -508,7 +469,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)", @@ -532,12 +492,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)", @@ -555,7 +512,6 @@ describe("nitro:preset:vercel:web", async () => { "functions/rules/swr/[...]-isr.func (symlink)", "functions/rules/swr/[...]-isr.prerender-config.json", "functions/single-headers/[id].func (symlink)", - "functions/slash.func (symlink)", "functions/static-flags.func (symlink)", "functions/stream.func (symlink)", "functions/tasks/[...name].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", + ]); + }); +});