Skip to content
Draft
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
66 changes: 52 additions & 14 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 Expand Up @@ -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([
Expand All @@ -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[] = [];
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>";
});
62 changes: 9 additions & 53 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 @@ -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",
Expand All @@ -280,10 +267,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 @@ -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",
Expand Down Expand Up @@ -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/(?<id>[^/]+)",
Expand Down Expand Up @@ -491,17 +454,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 @@ -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)",
Expand Down
Loading
Loading