Skip to content
Open
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
52 changes: 40 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, 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";
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 @@ -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<string, { path: string }> = {};

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 (`<dir>/index.*` at `<dir>`), 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;
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 @@ -248,6 +243,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 @@ -549,6 +548,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
68 changes: 68 additions & 0 deletions test/unit/vercel-overrides.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
Loading