Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/presets/vercel/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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[] = [];
Expand Down
44 changes: 0 additions & 44 deletions test/presets/vercel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -291,10 +279,6 @@ describe("nitro:preset:vercel:web", async () => {
"dest": "/jsx",
"src": "/jsx",
},
{
"dest": "/json-string",
"src": "/json-string",
},
{
"dest": "/imports",
"src": "/imports",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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/(?<id>[^/]+)",
Expand Down Expand Up @@ -498,17 +462,13 @@ 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)",
"functions/api/db.func (symlink)",
"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)",
Expand All @@ -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)",
Expand All @@ -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)",
Expand Down
158 changes: 158 additions & 0 deletions test/unit/vercel-observability-routes.test.ts
Original file line number Diff line number Diff line change
@@ -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/(?<slug>[^/]+)", 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",
]);
});
});
Loading