Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
48 changes: 36 additions & 12 deletions src/presets/vercel/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from "nitro/types";
import type { Nitro, NitroRouteRules, PrerenderRoute } from "nitro/types";
import { basename, dirname, relative, resolve } from "pathe";
import { Router } from "../../routing.ts";
import { joinURL, withLeadingSlash, withoutLeadingSlash } from "ufo";
Expand Down Expand Up @@ -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 `<dir>/index.html` (and extensionless `<dir>/index`) at `<dir>`
// 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]);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -388,6 +384,34 @@ 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<string, { path: string }> = {};

for (const { route, fileName } of prerenderedRoutes) {
// An override pointing a file at its own path would delete it
if (!fileName || fileName === route) {
continue;
}
const file = withoutLeadingSlash(fileName);
const path = route.replace(SURROUNDING_SLASH_RE, "");
if (file.replace(INDEX_FILE_RE, "") !== path) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
overrides[file] = { path };
}
}

return overrides;
}

export function deprecateSWR(nitro: Nitro) {
if (nitro.options.future.nativeSWR) {
return;
Expand Down
6 changes: 6 additions & 0 deletions test/presets/fixtures/slash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineHandler } from "nitro/h3";

export default defineHandler((event) => {
event.res.headers.set("content-type", "text/html");
return "<!DOCTYPE html><html><body>slash</body></html>";
});
28 changes: 14 additions & 14 deletions test/presets/vercel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -251,6 +246,10 @@ describe("nitro:preset:vercel:web", async () => {
"dest": "/static-flags",
"src": "/static-flags",
},
{
"dest": "/slash",
"src": "/slash",
},
{
"dest": "/route-group",
"src": "/route-group",
Expand Down Expand Up @@ -547,6 +546,7 @@ 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
66 changes: 66 additions & 0 deletions test/unit/vercel-overrides.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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: "/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({});
});
});