Skip to content

Commit b08a129

Browse files
authored
refactor(vercel): better o11y for isr cached routes (#3560)
1 parent e671be5 commit b08a129

2 files changed

Lines changed: 146 additions & 28 deletions

File tree

src/presets/vercel/utils.ts

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { defu } from "defu";
33
import { writeFile } from "nitropack/kit";
44
import type { Nitro } from "nitropack/types";
55
import { dirname, relative, resolve } from "pathe";
6-
import { joinURL, withoutLeadingSlash } from "ufo";
6+
import { joinURL, withLeadingSlash, withoutLeadingSlash } from "ufo";
77
import type {
88
PrerenderFunctionConfig,
99
VercelBuildConfigV3,
@@ -16,6 +16,10 @@ import { isTest } from "std-env";
1616
// https://vercel.com/docs/functions/runtimes/node-js/node-js-versions
1717
const SUPPORTED_NODE_VERSIONS = [18, 20, 22];
1818

19+
const FALLBACK_ROUTE = "/__fallback";
20+
21+
const SAFE_FS_CHAR_RE = /[^a-zA-Z0-9_.[\]/]/g;
22+
1923
function getSystemNodeVersion() {
2024
const systemNodeVersion = Number.parseInt(
2125
process.versions.node.split(".")[0]
@@ -84,7 +88,8 @@ export async function generateFunctionFiles(nitro: Nitro) {
8488

8589
const funcPrefix = resolve(
8690
nitro.options.output.serverDir,
87-
".." + generateEndpoint(key)
91+
"..",
92+
normalizeRouteDest(key) + ".isr"
8893
);
8994
await fsp.mkdir(dirname(funcPrefix), { recursive: true });
9095
await fsp.symlink(
@@ -207,33 +212,33 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) {
207212
.map(([key, value]) => {
208213
const src = key.replace(/^(.*)\/\*\*/, "(?<url>$1/.*)");
209214
if (value.isr === false) {
210-
// we need to write a rule to avoid route being shadowed by another cache rule elsewhere
215+
// We need to write a rule to avoid route being shadowed by another cache rule elsewhere
211216
return {
212217
src,
213-
dest: "/__fallback",
218+
dest: FALLBACK_ROUTE,
214219
};
215220
}
216221
return {
217222
src,
218223
dest:
219224
nitro.options.preset === "vercel-edge"
220-
? "/__fallback?url=$url"
221-
: generateEndpoint(key) + "?url=$url",
225+
? FALLBACK_ROUTE + "?url=$url"
226+
: withLeadingSlash(normalizeRouteDest(key) + ".isr?url=$url"),
222227
};
223228
}),
224229
// If we are using an ISR function for /, then we need to write this explicitly
225230
...(nitro.options.routeRules["/"]?.isr
226231
? [
227232
{
228233
src: "(?<url>/)",
229-
dest: "/__fallback-index?url=$url",
234+
dest: "/index.isr?url=$url",
230235
},
231236
]
232237
: []),
233238
// Observability routes
234239
...(o11Routes || []).map((route) => ({
235240
src: joinURL(nitro.options.baseURL, route.src),
236-
dest: "/" + route.dest,
241+
dest: withLeadingSlash(route.dest),
237242
})),
238243
// If we are using an ISR function as a fallback
239244
// then we do not need to output the below fallback route as well
@@ -242,24 +247,14 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) {
242247
: [
243248
{
244249
src: "/(.*)",
245-
dest: "/__fallback",
250+
dest: FALLBACK_ROUTE,
246251
},
247252
])
248253
);
249254

250255
return config;
251256
}
252257

253-
function generateEndpoint(url: string) {
254-
if (url === "/") {
255-
return "/__fallback-index";
256-
}
257-
return url.includes("/**")
258-
? "/__fallback-" +
259-
withoutLeadingSlash(url.replace(/\/\*\*.*/, "").replace(/[^a-z]/g, "-"))
260-
: url;
261-
}
262-
263258
export function deprecateSWR(nitro: Nitro) {
264259
if (nitro.options.future.nativeSWR) {
265260
return;
@@ -406,7 +401,7 @@ function normalizeRouteDest(route: string) {
406401
return segment;
407402
})
408403
// Only use filesystem-safe characters
409-
.map((segment) => segment.replace(/[^a-zA-Z0-9_.[\]]/g, "-"))
404+
.map((segment) => segment.replace(SAFE_FS_CHAR_RE, "-"))
410405
.join("/") || "index"
411406
);
412407
}

test/presets/vercel.test.ts

Lines changed: 131 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { promises as fsp } from "node:fs";
2-
import { resolve } from "pathe";
2+
import { resolve, join, relative, basename } from "pathe";
33
import { describe, expect, it } from "vitest";
44
import { setupTest, startServer, testNitro } from "../tests";
5+
import { readlink } from "node:fs/promises";
56

67
describe("nitro:preset:vercel", async () => {
78
const ctx = await setupTest("vercel");
@@ -113,7 +114,7 @@ describe("nitro:preset:vercel", async () => {
113114
"handle": "filesystem",
114115
},
115116
{
116-
"dest": "/rules/_/noncached/cached?url=$url",
117+
"dest": "/rules/_/noncached/cached.isr?url=$url",
117118
"src": "/rules/_/noncached/cached",
118119
},
119120
{
@@ -125,27 +126,27 @@ describe("nitro:preset:vercel", async () => {
125126
"src": "(?<url>/rules/_/noncached/.*)",
126127
},
127128
{
128-
"dest": "/__fallback--rules---cached?url=$url",
129+
"dest": "/rules/_/cached/[...].isr?url=$url",
129130
"src": "(?<url>/rules/_/cached/.*)",
130131
},
131132
{
132133
"dest": "/__fallback",
133134
"src": "/rules/dynamic",
134135
},
135136
{
136-
"dest": "/__fallback--rules-isr?url=$url",
137+
"dest": "/rules/isr/[...].isr?url=$url",
137138
"src": "(?<url>/rules/isr/.*)",
138139
},
139140
{
140-
"dest": "/__fallback--rules-isr-ttl?url=$url",
141+
"dest": "/rules/isr-ttl/[...].isr?url=$url",
141142
"src": "(?<url>/rules/isr-ttl/.*)",
142143
},
143144
{
144-
"dest": "/__fallback--rules-swr?url=$url",
145+
"dest": "/rules/swr/[...].isr?url=$url",
145146
"src": "(?<url>/rules/swr/.*)",
146147
},
147148
{
148-
"dest": "/__fallback--rules-swr-ttl?url=$url",
149+
"dest": "/rules/swr-ttl/[...].isr?url=$url",
149150
"src": "(?<url>/rules/swr-ttl/.*)",
150151
},
151152
{
@@ -450,7 +451,7 @@ describe("nitro:preset:vercel", async () => {
450451
const isrRouteConfig = await fsp.readFile(
451452
resolve(
452453
ctx.outDir,
453-
"functions/__fallback--rules-isr.prerender-config.json"
454+
"functions/rules/isr/[...].isr.prerender-config.json"
454455
),
455456
"utf8"
456457
);
@@ -459,6 +460,128 @@ describe("nitro:preset:vercel", async () => {
459460
allowQuery: ["q", "url"],
460461
});
461462
});
463+
464+
const walkDir = async (path: string): Promise<string[]> => {
465+
const items: string[] = [];
466+
const dirname = basename(path);
467+
const entries = await fsp.readdir(path, { withFileTypes: true });
468+
for (const entry of entries) {
469+
if (entry.isFile()) {
470+
items.push(`${dirname}/${entry.name}`);
471+
} else if (entry.isSymbolicLink()) {
472+
items.push(`${dirname}/${entry.name} (symlink)`);
473+
} else if (/chunks|node_modules/.test(entry.name)) {
474+
items.push(`${dirname}/${entry.name}`);
475+
} else if (entry.isDirectory()) {
476+
items.push(
477+
...(await walkDir(join(path, entry.name))).map(
478+
(i) => `${dirname}/${i}`
479+
)
480+
);
481+
}
482+
}
483+
return items;
484+
};
485+
486+
it("should generated expected functions", async () => {
487+
const functionsDir = resolve(ctx.outDir, "functions");
488+
const functionsFiles = await walkDir(functionsDir);
489+
expect(functionsFiles).toMatchInlineSnapshot(`
490+
[
491+
"functions/500.func (symlink)",
492+
"functions/__fallback.func/.vc-config.json",
493+
"functions/__fallback.func/chunks",
494+
"functions/__fallback.func/index.mjs",
495+
"functions/__fallback.func/index.mjs.map",
496+
"functions/__fallback.func/node_modules",
497+
"functions/__fallback.func/package.json",
498+
"functions/__fallback.func/timing.js",
499+
"functions/_openapi.json.func (symlink)",
500+
"functions/_scalar.func (symlink)",
501+
"functions/_swagger.func (symlink)",
502+
"functions/api/cached.func (symlink)",
503+
"functions/api/db.func (symlink)",
504+
"functions/api/echo.func (symlink)",
505+
"functions/api/error.func (symlink)",
506+
"functions/api/errors.func (symlink)",
507+
"functions/api/headers.func (symlink)",
508+
"functions/api/hello.func (symlink)",
509+
"functions/api/hello2.func (symlink)",
510+
"functions/api/hey.func (symlink)",
511+
"functions/api/import-meta.func (symlink)",
512+
"functions/api/kebab.func (symlink)",
513+
"functions/api/meta/test.func (symlink)",
514+
"functions/api/methods/default.func (symlink)",
515+
"functions/api/methods/foo.get.func (symlink)",
516+
"functions/api/methods/get.func (symlink)",
517+
"functions/api/methods.func (symlink)",
518+
"functions/api/param/[test-id].func (symlink)",
519+
"functions/api/serialized/date.func (symlink)",
520+
"functions/api/serialized/error.func (symlink)",
521+
"functions/api/serialized/function.func (symlink)",
522+
"functions/api/serialized/map.func (symlink)",
523+
"functions/api/serialized/null.func (symlink)",
524+
"functions/api/serialized/set.func (symlink)",
525+
"functions/api/serialized/tuple.func (symlink)",
526+
"functions/api/serialized/void.func (symlink)",
527+
"functions/api/storage/dev.func (symlink)",
528+
"functions/api/storage/item.func (symlink)",
529+
"functions/api/test/[-]/foo.func (symlink)",
530+
"functions/api/typed/catchall/[slug]/[...another].func (symlink)",
531+
"functions/api/typed/catchall/some/[...test].func (symlink)",
532+
"functions/api/typed/todos/[...].func (symlink)",
533+
"functions/api/typed/todos/[todoId]/comments/[...commentId].func (symlink)",
534+
"functions/api/typed/user/[userId]/[userExtends].func (symlink)",
535+
"functions/api/typed/user/[userId]/post/[postId].func (symlink)",
536+
"functions/api/typed/user/[userId]/post/firstPost.func (symlink)",
537+
"functions/api/typed/user/[userId].func (symlink)",
538+
"functions/api/typed/user/john/[johnExtends].func (symlink)",
539+
"functions/api/typed/user/john/post/[postId].func (symlink)",
540+
"functions/api/typed/user/john/post/coffee.func (symlink)",
541+
"functions/api/typed/user/john.func (symlink)",
542+
"functions/api/upload.func (symlink)",
543+
"functions/api/wildcard/[...param].func (symlink)",
544+
"functions/assets/[id].func (symlink)",
545+
"functions/assets/all.func (symlink)",
546+
"functions/assets/md.func (symlink)",
547+
"functions/config.func (symlink)",
548+
"functions/context.func (symlink)",
549+
"functions/env.func (symlink)",
550+
"functions/error-stack.func (symlink)",
551+
"functions/fetch.func (symlink)",
552+
"functions/file.func (symlink)",
553+
"functions/icon.png.func (symlink)",
554+
"functions/imports.func (symlink)",
555+
"functions/json-string.func (symlink)",
556+
"functions/jsx.func (symlink)",
557+
"functions/modules.func (symlink)",
558+
"functions/node-compat.func (symlink)",
559+
"functions/prerender-custom.html.func (symlink)",
560+
"functions/prerender.func (symlink)",
561+
"functions/raw.func (symlink)",
562+
"functions/route-group.func (symlink)",
563+
"functions/rules/[...slug].func (symlink)",
564+
"functions/rules/_/cached/[...].isr.func (symlink)",
565+
"functions/rules/_/cached/[...].isr.prerender-config.json",
566+
"functions/rules/_/noncached/cached.isr.func (symlink)",
567+
"functions/rules/_/noncached/cached.isr.prerender-config.json",
568+
"functions/rules/isr/[...].isr.func (symlink)",
569+
"functions/rules/isr/[...].isr.prerender-config.json",
570+
"functions/rules/isr-ttl/[...].isr.func (symlink)",
571+
"functions/rules/isr-ttl/[...].isr.prerender-config.json",
572+
"functions/rules/swr/[...].isr.func (symlink)",
573+
"functions/rules/swr/[...].isr.prerender-config.json",
574+
"functions/rules/swr-ttl/[...].isr.func (symlink)",
575+
"functions/rules/swr-ttl/[...].isr.prerender-config.json",
576+
"functions/static-flags.func (symlink)",
577+
"functions/stream.func (symlink)",
578+
"functions/tasks/[...name].func (symlink)",
579+
"functions/wait-until.func (symlink)",
580+
"functions/wasm/dynamic-import.func (symlink)",
581+
"functions/wasm/static-import.func (symlink)",
582+
]
583+
`);
584+
});
462585
}
463586
);
464587
});

0 commit comments

Comments
 (0)