From da1808e0fc9b239ffbe463e8cc67ff1fdb4b7f76 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Tue, 17 Mar 2026 15:37:04 +0000 Subject: [PATCH 01/17] feat(vercel): allow overriding function config by route --- src/presets/vercel/types.ts | 18 ++++++ src/presets/vercel/utils.ts | 114 +++++++++++++++++++++++++++++++----- test/presets/vercel.test.ts | 72 +++++++++++++++++++++++ 3 files changed, 190 insertions(+), 14 deletions(-) diff --git a/src/presets/vercel/types.ts b/src/presets/vercel/types.ts index 4c8bd608f2..7f92d6fd8b 100644 --- a/src/presets/vercel/types.ts +++ b/src/presets/vercel/types.ts @@ -147,6 +147,24 @@ export interface VercelOptions { * @see https://vercel.com/docs/cron-jobs */ cronHandlerRoute?: string; + + /** + * Per-route function configuration overrides. + * + * Keys are route patterns (e.g., `/api/queues/*`, `/api/slow-routes/**`). + * Values are partial {@link VercelServerlessFunctionConfig} objects. + * + * @example + * ```ts + * routeFunctionConfig: { + * '/api/my-slow-routes/**': { maxDuration: 3600 }, + * '/api/queues/fulfill-order': { + * experimentalTriggers: [{ type: 'queue/v2beta', topic: 'orders' }], + * }, + * } + * ``` + */ + routeFunctionConfig?: Record; } /** diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index 02447c8a0e..6dfc771939 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -3,6 +3,7 @@ import { defu } from "defu"; import { writeFile } from "../_utils/fs.ts"; import type { Nitro, NitroRouteRules } from "nitro/types"; import { dirname, relative, resolve } from "pathe"; +import { createRouter, addRoute, findRoute } from "rou3"; import { joinURL, withLeadingSlash, withoutLeadingSlash } from "ufo"; import type { PrerenderFunctionConfig, @@ -48,15 +49,26 @@ export async function generateFunctionFiles(nitro: Nitro) { const buildConfig = generateBuildConfig(nitro, o11Routes); await writeFile(buildConfigPath, JSON.stringify(buildConfig, null, 2)); - const functionConfigPath = resolve(nitro.options.output.serverDir, ".vc-config.json"); - const functionConfig: VercelServerlessFunctionConfig = { + const baseFunctionConfig: VercelServerlessFunctionConfig = { handler: "index.mjs", launcherType: "Nodejs", shouldAddHelpers: false, supportsResponseStreaming: true, ...nitro.options.vercel?.functions, }; - await writeFile(functionConfigPath, JSON.stringify(functionConfig, null, 2)); + const functionConfigPath = resolve(nitro.options.output.serverDir, ".vc-config.json"); + await writeFile(functionConfigPath, JSON.stringify(baseFunctionConfig, null, 2)); + + // Build rou3 router for routeFunctionConfig matching + const routeFunctionConfig = nitro.options.vercel?.routeFunctionConfig; + const hasRouteFunctionConfig = routeFunctionConfig && Object.keys(routeFunctionConfig).length > 0; + let routeFuncRouter: ReturnType> | undefined; + if (hasRouteFunctionConfig) { + routeFuncRouter = createRouter(); + for (const [pattern, overrides] of Object.entries(routeFunctionConfig)) { + addRoute(routeFuncRouter, "", pattern, overrides); + } + } // Write ISR functions for (const [key, value] of Object.entries(nitro.options.routeRules)) { @@ -70,11 +82,23 @@ export async function generateFunctionFiles(nitro: Nitro) { normalizeRouteDest(key) + ISR_SUFFIX ); await fsp.mkdir(dirname(funcPrefix), { recursive: true }); - await fsp.symlink( - "./" + relative(dirname(funcPrefix), nitro.options.output.serverDir), - funcPrefix + ".func", - "junction" - ); + + const match = routeFuncRouter && findRoute(routeFuncRouter, "", key); + if (match) { + await createFunctionDirWithCustomConfig( + funcPrefix + ".func", + nitro.options.output.serverDir, + baseFunctionConfig, + match.data + ); + } else { + await fsp.symlink( + "./" + relative(dirname(funcPrefix), nitro.options.output.serverDir), + funcPrefix + ".func", + "junction" + ); + } + await writePrerenderConfig( funcPrefix + ".prerender-config.json", value.isr, @@ -82,6 +106,25 @@ export async function generateFunctionFiles(nitro: Nitro) { ); } + // Write routeFunctionConfig custom function directories + const createdFuncDirs = new Set(); + if (hasRouteFunctionConfig) { + for (const [pattern, overrides] of Object.entries(routeFunctionConfig!)) { + const funcDir = resolve( + nitro.options.output.serverDir, + "..", + normalizeRouteDest(pattern) + ".func" + ); + await createFunctionDirWithCustomConfig( + funcDir, + nitro.options.output.serverDir, + baseFunctionConfig, + overrides + ); + createdFuncDirs.add(funcDir); + } + } + // Write observability routes if (o11Routes.length === 0) { return; @@ -94,12 +137,29 @@ export async function generateFunctionFiles(nitro: Nitro) { continue; // #3563 } const funcPrefix = resolve(nitro.options.output.serverDir, "..", route.dest); - await fsp.mkdir(dirname(funcPrefix), { recursive: true }); - await fsp.symlink( - "./" + relative(dirname(funcPrefix), nitro.options.output.serverDir), - funcPrefix + ".func", - "junction" - ); + const funcDir = funcPrefix + ".func"; + + // Skip if already created by routeFunctionConfig + if (createdFuncDirs.has(funcDir)) { + continue; + } + + const match = routeFuncRouter && findRoute(routeFuncRouter, "", route.src); + if (match) { + await createFunctionDirWithCustomConfig( + funcDir, + nitro.options.output.serverDir, + baseFunctionConfig, + match.data + ); + } else { + await fsp.mkdir(dirname(funcPrefix), { recursive: true }); + await fsp.symlink( + "./" + relative(dirname(funcPrefix), nitro.options.output.serverDir), + funcDir, + "junction" + ); + } } } @@ -273,6 +333,13 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { ), }; }), + // Route function config routes + ...(nitro.options.vercel?.routeFunctionConfig + ? Object.keys(nitro.options.vercel.routeFunctionConfig).map((pattern) => ({ + src: normalizeRouteSrc(pattern), + dest: withLeadingSlash(normalizeRouteDest(pattern)), + })) + : []), // Observability routes ...(o11Routes || []).map((route) => ({ src: joinURL(nitro.options.baseURL, route.src), @@ -512,6 +579,25 @@ function normalizeRouteDest(route: string) { ); } +async function createFunctionDirWithCustomConfig( + funcDir: string, + serverDir: string, + baseFunctionConfig: VercelServerlessFunctionConfig, + overrides: VercelServerlessFunctionConfig +) { + await fsp.mkdir(funcDir, { recursive: true }); + const entries = await fsp.readdir(serverDir); + for (const entry of entries) { + if (entry === ".vc-config.json") { + continue; + } + const target = "./" + relative(funcDir, resolve(serverDir, entry)); + await fsp.symlink(target, resolve(funcDir, entry), "junction"); + } + const mergedConfig = defu(overrides, baseFunctionConfig); + await writeFile(resolve(funcDir, ".vc-config.json"), JSON.stringify(mergedConfig, null, 2)); +} + async function writePrerenderConfig( filename: string, isrConfig: NitroRouteRules["isr"], diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index e61e808760..e5797fcfe0 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -531,6 +531,78 @@ describe("nitro:preset:vercel:bun", async () => { }); }); +describe("nitro:preset:vercel:route-function-config", async () => { + const ctx = await setupTest("vercel", { + outDirSuffix: "-route-func-config", + config: { + preset: "vercel", + vercel: { + routeFunctionConfig: { + "/api/hello": { + maxDuration: 300, + }, + "/api/echo": { + experimentalTriggers: [{ type: "queue/v2beta", topic: "orders" }], + }, + }, + }, + }, + }); + + it("should create custom function directory (not symlink)", async () => { + const funcDir = resolve(ctx.outDir, "functions/api/hello.func"); + const stat = await fsp.lstat(funcDir); + expect(stat.isDirectory()).toBe(true); + expect(stat.isSymbolicLink()).toBe(false); + }); + + it("should write merged .vc-config.json with overrides", async () => { + const config = await fsp + .readFile(resolve(ctx.outDir, "functions/api/hello.func/.vc-config.json"), "utf8") + .then((r) => JSON.parse(r)); + expect(config.maxDuration).toBe(300); + expect(config.handler).toBe("index.mjs"); + expect(config.launcherType).toBe("Nodejs"); + expect(config.supportsResponseStreaming).toBe(true); + }); + + it("should write custom config with arbitrary fields", async () => { + const config = await fsp + .readFile(resolve(ctx.outDir, "functions/api/echo.func/.vc-config.json"), "utf8") + .then((r) => JSON.parse(r)); + expect(config.experimentalTriggers).toEqual([{ type: "queue/v2beta", topic: "orders" }]); + expect(config.handler).toBe("index.mjs"); + }); + + it("should symlink files inside custom function directory to __server.func", async () => { + const funcDir = resolve(ctx.outDir, "functions/api/hello.func"); + const entries = await fsp.readdir(funcDir, { withFileTypes: true }); + const indexEntry = entries.find((e) => e.name === "index.mjs"); + expect(indexEntry).toBeDefined(); + const indexStat = await fsp.lstat(resolve(funcDir, "index.mjs")); + expect(indexStat.isSymbolicLink()).toBe(true); + }); + + it("should add routing entries for custom function routes in config.json", async () => { + const config = await fsp + .readFile(resolve(ctx.outDir, "config.json"), "utf8") + .then((r) => JSON.parse(r)); + const routes = config.routes as { src: string; dest: string }[]; + const helloRoute = routes.find((r) => r.dest === "/api/hello" && r.src === "/api/hello"); + expect(helloRoute).toBeDefined(); + const echoRoute = routes.find((r) => r.dest === "/api/echo" && r.src === "/api/echo"); + expect(echoRoute).toBeDefined(); + }); + + it("should keep base __server.func with standard config", async () => { + const config = await fsp + .readFile(resolve(ctx.outDir, "functions/__server.func/.vc-config.json"), "utf8") + .then((r) => JSON.parse(r)); + expect(config.maxDuration).toBeUndefined(); + expect(config.handler).toBe("index.mjs"); + }); +}); + describe.skip("nitro:preset:vercel:bun-verceljson", async () => { const vercelJsonPath = join(fixtureDir, "vercel.json"); From 24d92dc2eec2f3f28a642401c8f719418f46aff8 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 18 Mar 2026 12:26:00 +0000 Subject: [PATCH 02/17] fix: factor in base url with function route matching --- src/presets/vercel/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index 6dfc771939..e4f71f4c7d 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -336,7 +336,7 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { // Route function config routes ...(nitro.options.vercel?.routeFunctionConfig ? Object.keys(nitro.options.vercel.routeFunctionConfig).map((pattern) => ({ - src: normalizeRouteSrc(pattern), + src: joinURL(nitro.options.baseURL, normalizeRouteSrc(pattern)), dest: withLeadingSlash(normalizeRouteDest(pattern)), })) : []), From ba05eb0f65b61290d8b51a28f4a2e33265608d2b Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 18 Mar 2026 12:30:10 +0000 Subject: [PATCH 03/17] fix: replace arrays rather than merge --- src/presets/vercel/utils.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index e4f71f4c7d..6d443f12f2 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -595,6 +595,11 @@ async function createFunctionDirWithCustomConfig( await fsp.symlink(target, resolve(funcDir, entry), "junction"); } const mergedConfig = defu(overrides, baseFunctionConfig); + for (const [key, value] of Object.entries(overrides)) { + if (Array.isArray(value)) { + (mergedConfig as Record)[key] = value; + } + } await writeFile(resolve(funcDir, ".vc-config.json"), JSON.stringify(mergedConfig, null, 2)); } From 2758d9b43fd61a683e50c11e819f8908f337cd42 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 18 Mar 2026 13:17:48 +0000 Subject: [PATCH 04/17] docs(vercel): function config overrides --- docs/2.deploy/20.providers/vercel.md | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index e835edf324..dcb333efee 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -55,6 +55,35 @@ Alternatively, Nitro also detects Bun automatically if you specify a `bunVersion } ``` +## Per-route function configuration + +Use `vercel.routeFunctionConfig` to override [serverless function settings](https://vercel.com/docs/build-output-api/primitives#serverless-function-configuration) for specific routes. Each key is a route pattern and its value is a partial function configuration object that gets merged with the base `vercel.functions` config. + +This is useful when certain routes need different resource limits, regions, or features like [Vercel Queues triggers](https://vercel.com/docs/queues). + +```ts [nitro.config.ts] +import { defineNitroConfig } from "nitro/config"; + +export default defineNitroConfig({ + vercel: { + routeFunctionConfig: { + "/api/heavy-computation": { + maxDuration: 800, + memory: 4096, + }, + "/api/regional": { + regions: ["lhr1", "cdg1"], + }, + "/api/queues/process-order": { + experimentalTriggers: [{ type: "queue/v2beta", topic: "orders" }], + }, + }, + }, +}); +``` + +Route patterns support wildcards via [rou3](https://github.com/h3js/rou3) matching (e.g., `/api/slow/**` matches all routes under `/api/slow/`). + ## Proxy route rules Nitro automatically optimizes `proxy` route rules on Vercel by generating [CDN-level rewrites](https://vercel.com/docs/rewrites) at build time. This means matching requests are proxied at the edge without invoking a serverless function, reducing latency and cost. From af5775ab8a16ec08568c0c3780c642df1ee9a72e Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 18 Mar 2026 13:26:53 +0000 Subject: [PATCH 05/17] Update docs/2.deploy/20.providers/vercel.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- docs/2.deploy/20.providers/vercel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index dcb333efee..586ba05275 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -57,7 +57,7 @@ Alternatively, Nitro also detects Bun automatically if you specify a `bunVersion ## Per-route function configuration -Use `vercel.routeFunctionConfig` to override [serverless function settings](https://vercel.com/docs/build-output-api/primitives#serverless-function-configuration) for specific routes. Each key is a route pattern and its value is a partial function configuration object that gets merged with the base `vercel.functions` config. +Use `vercel.routeFunctionConfig` to override [serverless function settings](https://vercel.com/docs/build-output-api/primitives#serverless-function-configuration) for specific routes. Each key is a route pattern and its value is a partial function configuration object that gets merged with the base `vercel.functions` config. Note: array properties (e.g., `regions`) from route config will replace the base config arrays rather than merging them. This is useful when certain routes need different resource limits, regions, or features like [Vercel Queues triggers](https://vercel.com/docs/queues). From 0b8d4c551c6b0c32605d6b5ac0bae3b6a32ed8e0 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 18 Mar 2026 14:10:48 +0000 Subject: [PATCH 06/17] test(vercel): move to main fixture --- test/fixture/nitro.config.ts | 10 +++ test/presets/vercel.test.ts | 161 +++++++++++++++++++---------------- 2 files changed, 97 insertions(+), 74 deletions(-) diff --git a/test/fixture/nitro.config.ts b/test/fixture/nitro.config.ts index 53fdb7b36b..a315a842b4 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -4,6 +4,16 @@ import { dirname, resolve } from "node:path"; import { existsSync } from "node:fs"; export default defineConfig({ + vercel: { + routeFunctionConfig: { + "/api/hello": { + maxDuration: 300, + }, + "/api/echo": { + experimentalTriggers: [{ type: "queue/v2beta", topic: "orders" }], + }, + }, + }, compressPublicAssets: true, compatibilityDate: "latest", serverDir: "server", diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index e5797fcfe0..95ac42178d 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -167,6 +167,14 @@ describe("nitro:preset:vercel:web", async () => { "dest": "/rules/swr-ttl/[...]-isr?__isr_route=$__isr_route", "src": "(?<__isr_route>/rules/swr-ttl/(?:.*))", }, + { + "dest": "/api/hello", + "src": "/api/hello", + }, + { + "dest": "/api/echo", + "src": "/api/echo", + }, { "dest": "/wasm/static-import", "src": "/wasm/static-import", @@ -420,9 +428,47 @@ describe("nitro:preset:vercel:web", async () => { "functions/_vercel", "functions/api/cached.func (symlink)", "functions/api/db.func (symlink)", - "functions/api/echo.func (symlink)", + "functions/api/echo.func/.vc-config.json", + "functions/api/echo.func/_...name_.mjs (symlink)", + "functions/api/echo.func/_...name_.mjs.map (symlink)", + "functions/api/echo.func/_...param_.mjs (symlink)", + "functions/api/echo.func/_...param_.mjs.map (symlink)", + "functions/api/echo.func/_...slug_.mjs (symlink)", + "functions/api/echo.func/_...slug_.mjs.map (symlink)", + "functions/api/echo.func/_chunks (symlink)", + "functions/api/echo.func/_id_.mjs (symlink)", + "functions/api/echo.func/_id_.mjs.map (symlink)", + "functions/api/echo.func/_libs (symlink)", + "functions/api/echo.func/_routes (symlink)", + "functions/api/echo.func/_tasks (symlink)", + "functions/api/echo.func/_test-id_.mjs (symlink)", + "functions/api/echo.func/_test-id_.mjs.map (symlink)", + "functions/api/echo.func/_virtual (symlink)", + "functions/api/echo.func/index.mjs (symlink)", + "functions/api/echo.func/index.mjs.map (symlink)", + "functions/api/echo.func/node_modules (symlink)", + "functions/api/echo.func/package.json (symlink)", "functions/api/headers.func (symlink)", - "functions/api/hello.func (symlink)", + "functions/api/hello.func/.vc-config.json", + "functions/api/hello.func/_...name_.mjs (symlink)", + "functions/api/hello.func/_...name_.mjs.map (symlink)", + "functions/api/hello.func/_...param_.mjs (symlink)", + "functions/api/hello.func/_...param_.mjs.map (symlink)", + "functions/api/hello.func/_...slug_.mjs (symlink)", + "functions/api/hello.func/_...slug_.mjs.map (symlink)", + "functions/api/hello.func/_chunks (symlink)", + "functions/api/hello.func/_id_.mjs (symlink)", + "functions/api/hello.func/_id_.mjs.map (symlink)", + "functions/api/hello.func/_libs (symlink)", + "functions/api/hello.func/_routes (symlink)", + "functions/api/hello.func/_tasks (symlink)", + "functions/api/hello.func/_test-id_.mjs (symlink)", + "functions/api/hello.func/_test-id_.mjs.map (symlink)", + "functions/api/hello.func/_virtual (symlink)", + "functions/api/hello.func/index.mjs (symlink)", + "functions/api/hello.func/index.mjs.map (symlink)", + "functions/api/hello.func/node_modules (symlink)", + "functions/api/hello.func/package.json (symlink)", "functions/api/hey.func (symlink)", "functions/api/kebab.func (symlink)", "functions/api/meta/test.func (symlink)", @@ -478,6 +524,45 @@ describe("nitro:preset:vercel:web", async () => { ] `); }); + + it("should create custom function directory for routeFunctionConfig (not symlink)", async () => { + const funcDir = resolve(ctx.outDir, "functions/api/hello.func"); + const stat = await fsp.lstat(funcDir); + expect(stat.isDirectory()).toBe(true); + expect(stat.isSymbolicLink()).toBe(false); + }); + + it("should write merged .vc-config.json with routeFunctionConfig overrides", async () => { + const config = await fsp + .readFile(resolve(ctx.outDir, "functions/api/hello.func/.vc-config.json"), "utf8") + .then((r) => JSON.parse(r)); + expect(config.maxDuration).toBe(300); + expect(config.handler).toBe("index.mjs"); + expect(config.launcherType).toBe("Nodejs"); + expect(config.supportsResponseStreaming).toBe(true); + }); + + it("should write routeFunctionConfig with arbitrary fields", async () => { + const config = await fsp + .readFile(resolve(ctx.outDir, "functions/api/echo.func/.vc-config.json"), "utf8") + .then((r) => JSON.parse(r)); + expect(config.experimentalTriggers).toEqual([{ type: "queue/v2beta", topic: "orders" }]); + expect(config.handler).toBe("index.mjs"); + }); + + it("should symlink files inside routeFunctionConfig directory to __server.func", async () => { + const funcDir = resolve(ctx.outDir, "functions/api/hello.func"); + const indexStat = await fsp.lstat(resolve(funcDir, "index.mjs")); + expect(indexStat.isSymbolicLink()).toBe(true); + }); + + it("should keep base __server.func without routeFunctionConfig overrides", async () => { + const config = await fsp + .readFile(resolve(ctx.outDir, "functions/__server.func/.vc-config.json"), "utf8") + .then((r) => JSON.parse(r)); + expect(config.maxDuration).toBeUndefined(); + expect(config.handler).toBe("index.mjs"); + }); } ); }); @@ -531,78 +616,6 @@ describe("nitro:preset:vercel:bun", async () => { }); }); -describe("nitro:preset:vercel:route-function-config", async () => { - const ctx = await setupTest("vercel", { - outDirSuffix: "-route-func-config", - config: { - preset: "vercel", - vercel: { - routeFunctionConfig: { - "/api/hello": { - maxDuration: 300, - }, - "/api/echo": { - experimentalTriggers: [{ type: "queue/v2beta", topic: "orders" }], - }, - }, - }, - }, - }); - - it("should create custom function directory (not symlink)", async () => { - const funcDir = resolve(ctx.outDir, "functions/api/hello.func"); - const stat = await fsp.lstat(funcDir); - expect(stat.isDirectory()).toBe(true); - expect(stat.isSymbolicLink()).toBe(false); - }); - - it("should write merged .vc-config.json with overrides", async () => { - const config = await fsp - .readFile(resolve(ctx.outDir, "functions/api/hello.func/.vc-config.json"), "utf8") - .then((r) => JSON.parse(r)); - expect(config.maxDuration).toBe(300); - expect(config.handler).toBe("index.mjs"); - expect(config.launcherType).toBe("Nodejs"); - expect(config.supportsResponseStreaming).toBe(true); - }); - - it("should write custom config with arbitrary fields", async () => { - const config = await fsp - .readFile(resolve(ctx.outDir, "functions/api/echo.func/.vc-config.json"), "utf8") - .then((r) => JSON.parse(r)); - expect(config.experimentalTriggers).toEqual([{ type: "queue/v2beta", topic: "orders" }]); - expect(config.handler).toBe("index.mjs"); - }); - - it("should symlink files inside custom function directory to __server.func", async () => { - const funcDir = resolve(ctx.outDir, "functions/api/hello.func"); - const entries = await fsp.readdir(funcDir, { withFileTypes: true }); - const indexEntry = entries.find((e) => e.name === "index.mjs"); - expect(indexEntry).toBeDefined(); - const indexStat = await fsp.lstat(resolve(funcDir, "index.mjs")); - expect(indexStat.isSymbolicLink()).toBe(true); - }); - - it("should add routing entries for custom function routes in config.json", async () => { - const config = await fsp - .readFile(resolve(ctx.outDir, "config.json"), "utf8") - .then((r) => JSON.parse(r)); - const routes = config.routes as { src: string; dest: string }[]; - const helloRoute = routes.find((r) => r.dest === "/api/hello" && r.src === "/api/hello"); - expect(helloRoute).toBeDefined(); - const echoRoute = routes.find((r) => r.dest === "/api/echo" && r.src === "/api/echo"); - expect(echoRoute).toBeDefined(); - }); - - it("should keep base __server.func with standard config", async () => { - const config = await fsp - .readFile(resolve(ctx.outDir, "functions/__server.func/.vc-config.json"), "utf8") - .then((r) => JSON.parse(r)); - expect(config.maxDuration).toBeUndefined(); - expect(config.handler).toBe("index.mjs"); - }); -}); - describe.skip("nitro:preset:vercel:bun-verceljson", async () => { const vercelJsonPath = join(fixtureDir, "vercel.json"); From ac313e11bb55840a92a40a86dba0ce7e91d39f62 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 14:21:00 -0700 Subject: [PATCH 07/17] feat(vercel): generate consumer for queue triggers --- src/presets/vercel/utils.ts | 58 +++++++++++++++++++++++++++++++++++-- test/presets/vercel.test.ts | 4 ++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index 6d443f12f2..811fca64ef 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -36,6 +36,29 @@ const ISR_SUFFIX = "-isr"; // Avoid using . as it can conflict with routing const SAFE_FS_CHAR_RE = /[^a-zA-Z0-9_.[\]/]/g; +/** + * Encodes a function path into a consumer name for queue/v2beta triggers. + * Mirrors the encoding from @vercel/build-utils sanitizeConsumerName(). + * @see https://github.com/vercel/vercel/blob/main/packages/build-utils/src/lambda.ts + */ +function sanitizeConsumerName(functionPath: string): string { + let result = ""; + for (const char of functionPath) { + if (char === "_") { + result += "__"; + } else if (char === "/") { + result += "_S"; + } else if (char === ".") { + result += "_D"; + } else if (/[A-Za-z0-9-]/.test(char)) { + result += char; + } else { + result += "_" + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0"); + } + } + return result; +} + function getSystemNodeVersion() { const systemNodeVersion = Number.parseInt(process.versions.node.split(".")[0]); @@ -56,6 +79,18 @@ export async function generateFunctionFiles(nitro: Nitro) { supportsResponseStreaming: true, ...nitro.options.vercel?.functions, }; + + if ( + Array.isArray(baseFunctionConfig.experimentalTriggers) && + baseFunctionConfig.experimentalTriggers.length > 0 + ) { + nitro.logger.warn( + "`experimentalTriggers` on the base `vercel.functions` config applies to the catch-all function and is likely not what you want. " + + "Routes with queue triggers are not accesible on the web." + + "Use `vercel.routeFunctionConfig` to attach triggers to specific routes instead." + ); + } + const functionConfigPath = resolve(nitro.options.output.serverDir, ".vc-config.json"); await writeFile(functionConfigPath, JSON.stringify(baseFunctionConfig, null, 2)); @@ -89,7 +124,8 @@ export async function generateFunctionFiles(nitro: Nitro) { funcPrefix + ".func", nitro.options.output.serverDir, baseFunctionConfig, - match.data + match.data, + normalizeRouteDest(key) + ISR_SUFFIX ); } else { await fsp.symlink( @@ -119,7 +155,8 @@ export async function generateFunctionFiles(nitro: Nitro) { funcDir, nitro.options.output.serverDir, baseFunctionConfig, - overrides + overrides, + normalizeRouteDest(pattern) ); createdFuncDirs.add(funcDir); } @@ -150,7 +187,8 @@ export async function generateFunctionFiles(nitro: Nitro) { funcDir, nitro.options.output.serverDir, baseFunctionConfig, - match.data + match.data, + route.dest ); } else { await fsp.mkdir(dirname(funcPrefix), { recursive: true }); @@ -583,6 +621,9 @@ async function createFunctionDirWithCustomConfig( funcDir: string, serverDir: string, baseFunctionConfig: VercelServerlessFunctionConfig, + overrides: VercelServerlessFunctionConfig, + functionPath: string +) { overrides: VercelServerlessFunctionConfig ) { await fsp.mkdir(funcDir, { recursive: true }); @@ -600,6 +641,17 @@ async function createFunctionDirWithCustomConfig( (mergedConfig as Record)[key] = value; } } + + // Auto-derive consumer for queue/v2beta triggers + const triggers = mergedConfig.experimentalTriggers; + if (Array.isArray(triggers)) { + for (const trigger of triggers as Array>) { + if (trigger.type === "queue/v2beta" && !trigger.consumer) { + trigger.consumer = sanitizeConsumerName(functionPath); + } + } + } + await writeFile(resolve(funcDir, ".vc-config.json"), JSON.stringify(mergedConfig, null, 2)); } diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index 95ac42178d..147f13b6aa 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -546,7 +546,9 @@ describe("nitro:preset:vercel:web", async () => { const config = await fsp .readFile(resolve(ctx.outDir, "functions/api/echo.func/.vc-config.json"), "utf8") .then((r) => JSON.parse(r)); - expect(config.experimentalTriggers).toEqual([{ type: "queue/v2beta", topic: "orders" }]); + expect(config.experimentalTriggers).toEqual([ + { type: "queue/v2beta", topic: "orders", consumer: "api_Secho" }, + ]); expect(config.handler).toBe("index.mjs"); }); From 1dd21d5f1a6d22034bd66912fdb50fd3ea4f8ef2 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 14:22:05 -0700 Subject: [PATCH 08/17] fix(vercel): copy function output instead of symlinking when config overridden --- src/presets/vercel/utils.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index 811fca64ef..f4cf8ef039 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -624,17 +624,11 @@ async function createFunctionDirWithCustomConfig( overrides: VercelServerlessFunctionConfig, functionPath: string ) { - overrides: VercelServerlessFunctionConfig -) { - await fsp.mkdir(funcDir, { recursive: true }); - const entries = await fsp.readdir(serverDir); - for (const entry of entries) { - if (entry === ".vc-config.json") { - continue; - } - const target = "./" + relative(funcDir, resolve(serverDir, entry)); - await fsp.symlink(target, resolve(funcDir, entry), "junction"); - } + // Copy the entire server directory instead of symlinking individual + // entries. Vercel's build container preserves symlinks in the Lambda + // zip, but symlinks pointing outside the .func directory break at + // runtime because the target path doesn't exist on Lambda. + await fsp.cp(serverDir, funcDir, { recursive: true }); const mergedConfig = defu(overrides, baseFunctionConfig); for (const [key, value] of Object.entries(overrides)) { if (Array.isArray(value)) { From fba759cd6697539e9a2e479ea32f15a17dbfc9d8 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 14:22:22 -0700 Subject: [PATCH 09/17] test: update fixture --- test/fixture/nitro.config.ts | 5 +++- test/presets/vercel.test.ts | 47 ++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/test/fixture/nitro.config.ts b/test/fixture/nitro.config.ts index a315a842b4..ba3bd84d67 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -7,11 +7,14 @@ export default defineConfig({ vercel: { routeFunctionConfig: { "/api/hello": { - maxDuration: 300, + maxDuration: 100, }, "/api/echo": { experimentalTriggers: [{ type: "queue/v2beta", topic: "orders" }], }, + "/rules/isr/**": { + regions: ["lhr1", "cdg1"], + }, }, }, compressPublicAssets: true, diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index 147f13b6aa..b4fc2efa34 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -175,6 +175,10 @@ describe("nitro:preset:vercel:web", async () => { "dest": "/api/echo", "src": "/api/echo", }, + { + "dest": "/rules/isr/[...]", + "src": "/rules/isr/(?:.*)", + }, { "dest": "/wasm/static-import", "src": "/wasm/static-import", @@ -508,8 +512,47 @@ describe("nitro:preset:vercel:web", async () => { "functions/rules/_/noncached/cached-isr.prerender-config.json", "functions/rules/isr-ttl/[...]-isr.func (symlink)", "functions/rules/isr-ttl/[...]-isr.prerender-config.json", - "functions/rules/isr/[...]-isr.func (symlink)", + "functions/rules/isr/[...]-isr.func/.vc-config.json", + "functions/rules/isr/[...]-isr.func/_...name_.mjs (symlink)", + "functions/rules/isr/[...]-isr.func/_...name_.mjs.map (symlink)", + "functions/rules/isr/[...]-isr.func/_...param_.mjs (symlink)", + "functions/rules/isr/[...]-isr.func/_...param_.mjs.map (symlink)", + "functions/rules/isr/[...]-isr.func/_...slug_.mjs (symlink)", + "functions/rules/isr/[...]-isr.func/_...slug_.mjs.map (symlink)", + "functions/rules/isr/[...]-isr.func/_chunks (symlink)", + "functions/rules/isr/[...]-isr.func/_id_.mjs (symlink)", + "functions/rules/isr/[...]-isr.func/_id_.mjs.map (symlink)", + "functions/rules/isr/[...]-isr.func/_libs (symlink)", + "functions/rules/isr/[...]-isr.func/_routes (symlink)", + "functions/rules/isr/[...]-isr.func/_tasks (symlink)", + "functions/rules/isr/[...]-isr.func/_test-id_.mjs (symlink)", + "functions/rules/isr/[...]-isr.func/_test-id_.mjs.map (symlink)", + "functions/rules/isr/[...]-isr.func/_virtual (symlink)", + "functions/rules/isr/[...]-isr.func/index.mjs (symlink)", + "functions/rules/isr/[...]-isr.func/index.mjs.map (symlink)", + "functions/rules/isr/[...]-isr.func/node_modules (symlink)", + "functions/rules/isr/[...]-isr.func/package.json (symlink)", "functions/rules/isr/[...]-isr.prerender-config.json", + "functions/rules/isr/[...].func/.vc-config.json", + "functions/rules/isr/[...].func/_...name_.mjs (symlink)", + "functions/rules/isr/[...].func/_...name_.mjs.map (symlink)", + "functions/rules/isr/[...].func/_...param_.mjs (symlink)", + "functions/rules/isr/[...].func/_...param_.mjs.map (symlink)", + "functions/rules/isr/[...].func/_...slug_.mjs (symlink)", + "functions/rules/isr/[...].func/_...slug_.mjs.map (symlink)", + "functions/rules/isr/[...].func/_chunks (symlink)", + "functions/rules/isr/[...].func/_id_.mjs (symlink)", + "functions/rules/isr/[...].func/_id_.mjs.map (symlink)", + "functions/rules/isr/[...].func/_libs (symlink)", + "functions/rules/isr/[...].func/_routes (symlink)", + "functions/rules/isr/[...].func/_tasks (symlink)", + "functions/rules/isr/[...].func/_test-id_.mjs (symlink)", + "functions/rules/isr/[...].func/_test-id_.mjs.map (symlink)", + "functions/rules/isr/[...].func/_virtual (symlink)", + "functions/rules/isr/[...].func/index.mjs (symlink)", + "functions/rules/isr/[...].func/index.mjs.map (symlink)", + "functions/rules/isr/[...].func/node_modules (symlink)", + "functions/rules/isr/[...].func/package.json (symlink)", "functions/rules/swr-ttl/[...]-isr.func (symlink)", "functions/rules/swr-ttl/[...]-isr.prerender-config.json", "functions/rules/swr/[...]-isr.func (symlink)", @@ -536,7 +579,7 @@ describe("nitro:preset:vercel:web", async () => { const config = await fsp .readFile(resolve(ctx.outDir, "functions/api/hello.func/.vc-config.json"), "utf8") .then((r) => JSON.parse(r)); - expect(config.maxDuration).toBe(300); + expect(config.maxDuration).toBe(100); expect(config.handler).toBe("index.mjs"); expect(config.launcherType).toBe("Nodejs"); expect(config.supportsResponseStreaming).toBe(true); From 90d8bb66f9e2493a4088dfe64b1ad5f21c36ddfa Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 14:23:05 -0700 Subject: [PATCH 10/17] test: copy function output instead of symlinking when config overridden --- test/presets/vercel.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index b4fc2efa34..d6e145e39b 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -595,10 +595,10 @@ describe("nitro:preset:vercel:web", async () => { expect(config.handler).toBe("index.mjs"); }); - it("should symlink files inside routeFunctionConfig directory to __server.func", async () => { + it("should copy files inside routeFunctionConfig directory from __server.func", async () => { const funcDir = resolve(ctx.outDir, "functions/api/hello.func"); const indexStat = await fsp.lstat(resolve(funcDir, "index.mjs")); - expect(indexStat.isSymbolicLink()).toBe(true); + expect(indexStat.isFile()).toBe(true); }); it("should keep base __server.func without routeFunctionConfig overrides", async () => { From beb380e9efb0efa5155049cdc03a1663cdd0aedf Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 14:45:58 -0700 Subject: [PATCH 11/17] test: copy function output instead of symlinking when config overridden --- test/presets/vercel.test.ts | 152 ++++++++++++++++++------------------ 1 file changed, 76 insertions(+), 76 deletions(-) diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index d6e145e39b..f1253483ed 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -433,46 +433,46 @@ describe("nitro:preset:vercel:web", async () => { "functions/api/cached.func (symlink)", "functions/api/db.func (symlink)", "functions/api/echo.func/.vc-config.json", - "functions/api/echo.func/_...name_.mjs (symlink)", - "functions/api/echo.func/_...name_.mjs.map (symlink)", - "functions/api/echo.func/_...param_.mjs (symlink)", - "functions/api/echo.func/_...param_.mjs.map (symlink)", - "functions/api/echo.func/_...slug_.mjs (symlink)", - "functions/api/echo.func/_...slug_.mjs.map (symlink)", - "functions/api/echo.func/_chunks (symlink)", - "functions/api/echo.func/_id_.mjs (symlink)", - "functions/api/echo.func/_id_.mjs.map (symlink)", - "functions/api/echo.func/_libs (symlink)", - "functions/api/echo.func/_routes (symlink)", - "functions/api/echo.func/_tasks (symlink)", - "functions/api/echo.func/_test-id_.mjs (symlink)", - "functions/api/echo.func/_test-id_.mjs.map (symlink)", - "functions/api/echo.func/_virtual (symlink)", - "functions/api/echo.func/index.mjs (symlink)", - "functions/api/echo.func/index.mjs.map (symlink)", - "functions/api/echo.func/node_modules (symlink)", - "functions/api/echo.func/package.json (symlink)", + "functions/api/echo.func/_...name_.mjs", + "functions/api/echo.func/_...name_.mjs.map", + "functions/api/echo.func/_...param_.mjs", + "functions/api/echo.func/_...param_.mjs.map", + "functions/api/echo.func/_...slug_.mjs", + "functions/api/echo.func/_...slug_.mjs.map", + "functions/api/echo.func/_chunks", + "functions/api/echo.func/_id_.mjs", + "functions/api/echo.func/_id_.mjs.map", + "functions/api/echo.func/_libs", + "functions/api/echo.func/_routes", + "functions/api/echo.func/_tasks", + "functions/api/echo.func/_test-id_.mjs", + "functions/api/echo.func/_test-id_.mjs.map", + "functions/api/echo.func/_virtual", + "functions/api/echo.func/index.mjs", + "functions/api/echo.func/index.mjs.map", + "functions/api/echo.func/node_modules", + "functions/api/echo.func/package.json", "functions/api/headers.func (symlink)", "functions/api/hello.func/.vc-config.json", - "functions/api/hello.func/_...name_.mjs (symlink)", - "functions/api/hello.func/_...name_.mjs.map (symlink)", - "functions/api/hello.func/_...param_.mjs (symlink)", - "functions/api/hello.func/_...param_.mjs.map (symlink)", - "functions/api/hello.func/_...slug_.mjs (symlink)", - "functions/api/hello.func/_...slug_.mjs.map (symlink)", - "functions/api/hello.func/_chunks (symlink)", - "functions/api/hello.func/_id_.mjs (symlink)", - "functions/api/hello.func/_id_.mjs.map (symlink)", - "functions/api/hello.func/_libs (symlink)", - "functions/api/hello.func/_routes (symlink)", - "functions/api/hello.func/_tasks (symlink)", - "functions/api/hello.func/_test-id_.mjs (symlink)", - "functions/api/hello.func/_test-id_.mjs.map (symlink)", - "functions/api/hello.func/_virtual (symlink)", - "functions/api/hello.func/index.mjs (symlink)", - "functions/api/hello.func/index.mjs.map (symlink)", - "functions/api/hello.func/node_modules (symlink)", - "functions/api/hello.func/package.json (symlink)", + "functions/api/hello.func/_...name_.mjs", + "functions/api/hello.func/_...name_.mjs.map", + "functions/api/hello.func/_...param_.mjs", + "functions/api/hello.func/_...param_.mjs.map", + "functions/api/hello.func/_...slug_.mjs", + "functions/api/hello.func/_...slug_.mjs.map", + "functions/api/hello.func/_chunks", + "functions/api/hello.func/_id_.mjs", + "functions/api/hello.func/_id_.mjs.map", + "functions/api/hello.func/_libs", + "functions/api/hello.func/_routes", + "functions/api/hello.func/_tasks", + "functions/api/hello.func/_test-id_.mjs", + "functions/api/hello.func/_test-id_.mjs.map", + "functions/api/hello.func/_virtual", + "functions/api/hello.func/index.mjs", + "functions/api/hello.func/index.mjs.map", + "functions/api/hello.func/node_modules", + "functions/api/hello.func/package.json", "functions/api/hey.func (symlink)", "functions/api/kebab.func (symlink)", "functions/api/meta/test.func (symlink)", @@ -513,46 +513,46 @@ describe("nitro:preset:vercel:web", async () => { "functions/rules/isr-ttl/[...]-isr.func (symlink)", "functions/rules/isr-ttl/[...]-isr.prerender-config.json", "functions/rules/isr/[...]-isr.func/.vc-config.json", - "functions/rules/isr/[...]-isr.func/_...name_.mjs (symlink)", - "functions/rules/isr/[...]-isr.func/_...name_.mjs.map (symlink)", - "functions/rules/isr/[...]-isr.func/_...param_.mjs (symlink)", - "functions/rules/isr/[...]-isr.func/_...param_.mjs.map (symlink)", - "functions/rules/isr/[...]-isr.func/_...slug_.mjs (symlink)", - "functions/rules/isr/[...]-isr.func/_...slug_.mjs.map (symlink)", - "functions/rules/isr/[...]-isr.func/_chunks (symlink)", - "functions/rules/isr/[...]-isr.func/_id_.mjs (symlink)", - "functions/rules/isr/[...]-isr.func/_id_.mjs.map (symlink)", - "functions/rules/isr/[...]-isr.func/_libs (symlink)", - "functions/rules/isr/[...]-isr.func/_routes (symlink)", - "functions/rules/isr/[...]-isr.func/_tasks (symlink)", - "functions/rules/isr/[...]-isr.func/_test-id_.mjs (symlink)", - "functions/rules/isr/[...]-isr.func/_test-id_.mjs.map (symlink)", - "functions/rules/isr/[...]-isr.func/_virtual (symlink)", - "functions/rules/isr/[...]-isr.func/index.mjs (symlink)", - "functions/rules/isr/[...]-isr.func/index.mjs.map (symlink)", - "functions/rules/isr/[...]-isr.func/node_modules (symlink)", - "functions/rules/isr/[...]-isr.func/package.json (symlink)", + "functions/rules/isr/[...]-isr.func/_...name_.mjs", + "functions/rules/isr/[...]-isr.func/_...name_.mjs.map", + "functions/rules/isr/[...]-isr.func/_...param_.mjs", + "functions/rules/isr/[...]-isr.func/_...param_.mjs.map", + "functions/rules/isr/[...]-isr.func/_...slug_.mjs", + "functions/rules/isr/[...]-isr.func/_...slug_.mjs.map", + "functions/rules/isr/[...]-isr.func/_chunks", + "functions/rules/isr/[...]-isr.func/_id_.mjs", + "functions/rules/isr/[...]-isr.func/_id_.mjs.map", + "functions/rules/isr/[...]-isr.func/_libs", + "functions/rules/isr/[...]-isr.func/_routes", + "functions/rules/isr/[...]-isr.func/_tasks", + "functions/rules/isr/[...]-isr.func/_test-id_.mjs", + "functions/rules/isr/[...]-isr.func/_test-id_.mjs.map", + "functions/rules/isr/[...]-isr.func/_virtual", + "functions/rules/isr/[...]-isr.func/index.mjs", + "functions/rules/isr/[...]-isr.func/index.mjs.map", + "functions/rules/isr/[...]-isr.func/node_modules", + "functions/rules/isr/[...]-isr.func/package.json", "functions/rules/isr/[...]-isr.prerender-config.json", "functions/rules/isr/[...].func/.vc-config.json", - "functions/rules/isr/[...].func/_...name_.mjs (symlink)", - "functions/rules/isr/[...].func/_...name_.mjs.map (symlink)", - "functions/rules/isr/[...].func/_...param_.mjs (symlink)", - "functions/rules/isr/[...].func/_...param_.mjs.map (symlink)", - "functions/rules/isr/[...].func/_...slug_.mjs (symlink)", - "functions/rules/isr/[...].func/_...slug_.mjs.map (symlink)", - "functions/rules/isr/[...].func/_chunks (symlink)", - "functions/rules/isr/[...].func/_id_.mjs (symlink)", - "functions/rules/isr/[...].func/_id_.mjs.map (symlink)", - "functions/rules/isr/[...].func/_libs (symlink)", - "functions/rules/isr/[...].func/_routes (symlink)", - "functions/rules/isr/[...].func/_tasks (symlink)", - "functions/rules/isr/[...].func/_test-id_.mjs (symlink)", - "functions/rules/isr/[...].func/_test-id_.mjs.map (symlink)", - "functions/rules/isr/[...].func/_virtual (symlink)", - "functions/rules/isr/[...].func/index.mjs (symlink)", - "functions/rules/isr/[...].func/index.mjs.map (symlink)", - "functions/rules/isr/[...].func/node_modules (symlink)", - "functions/rules/isr/[...].func/package.json (symlink)", + "functions/rules/isr/[...].func/_...name_.mjs", + "functions/rules/isr/[...].func/_...name_.mjs.map", + "functions/rules/isr/[...].func/_...param_.mjs", + "functions/rules/isr/[...].func/_...param_.mjs.map", + "functions/rules/isr/[...].func/_...slug_.mjs", + "functions/rules/isr/[...].func/_...slug_.mjs.map", + "functions/rules/isr/[...].func/_chunks", + "functions/rules/isr/[...].func/_id_.mjs", + "functions/rules/isr/[...].func/_id_.mjs.map", + "functions/rules/isr/[...].func/_libs", + "functions/rules/isr/[...].func/_routes", + "functions/rules/isr/[...].func/_tasks", + "functions/rules/isr/[...].func/_test-id_.mjs", + "functions/rules/isr/[...].func/_test-id_.mjs.map", + "functions/rules/isr/[...].func/_virtual", + "functions/rules/isr/[...].func/index.mjs", + "functions/rules/isr/[...].func/index.mjs.map", + "functions/rules/isr/[...].func/node_modules", + "functions/rules/isr/[...].func/package.json", "functions/rules/swr-ttl/[...]-isr.func (symlink)", "functions/rules/swr-ttl/[...]-isr.prerender-config.json", "functions/rules/swr/[...]-isr.func (symlink)", From 54892f1b056f24e7edf791067d3f487ae7bab19f Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 15:03:52 -0700 Subject: [PATCH 12/17] refactor: use nitro routing --- src/presets/vercel/utils.ts | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index f4cf8ef039..c2568818a6 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -3,7 +3,7 @@ import { defu } from "defu"; import { writeFile } from "../_utils/fs.ts"; import type { Nitro, NitroRouteRules } from "nitro/types"; import { dirname, relative, resolve } from "pathe"; -import { createRouter, addRoute, findRoute } from "rou3"; +import { Router } from "../../routing.ts"; import { joinURL, withLeadingSlash, withoutLeadingSlash } from "ufo"; import type { PrerenderFunctionConfig, @@ -94,15 +94,18 @@ export async function generateFunctionFiles(nitro: Nitro) { const functionConfigPath = resolve(nitro.options.output.serverDir, ".vc-config.json"); await writeFile(functionConfigPath, JSON.stringify(baseFunctionConfig, null, 2)); - // Build rou3 router for routeFunctionConfig matching const routeFunctionConfig = nitro.options.vercel?.routeFunctionConfig; const hasRouteFunctionConfig = routeFunctionConfig && Object.keys(routeFunctionConfig).length > 0; - let routeFuncRouter: ReturnType> | undefined; + let routeFuncRouter: Router | undefined; if (hasRouteFunctionConfig) { - routeFuncRouter = createRouter(); - for (const [pattern, overrides] of Object.entries(routeFunctionConfig)) { - addRoute(routeFuncRouter, "", pattern, overrides); - } + routeFuncRouter = new Router(); + routeFuncRouter._update( + Object.entries(routeFunctionConfig).map(([route, data]) => ({ + route, + method: "", + data, + })) + ); } // Write ISR functions @@ -118,13 +121,13 @@ export async function generateFunctionFiles(nitro: Nitro) { ); await fsp.mkdir(dirname(funcPrefix), { recursive: true }); - const match = routeFuncRouter && findRoute(routeFuncRouter, "", key); - if (match) { + const matchData = routeFuncRouter?.match("", key); + if (matchData) { await createFunctionDirWithCustomConfig( funcPrefix + ".func", nitro.options.output.serverDir, baseFunctionConfig, - match.data, + matchData, normalizeRouteDest(key) + ISR_SUFFIX ); } else { @@ -181,13 +184,13 @@ export async function generateFunctionFiles(nitro: Nitro) { continue; } - const match = routeFuncRouter && findRoute(routeFuncRouter, "", route.src); - if (match) { + const matchData = routeFuncRouter?.match("", route.src); + if (matchData) { await createFunctionDirWithCustomConfig( funcDir, nitro.options.output.serverDir, baseFunctionConfig, - match.data, + matchData, route.dest ); } else { From 8c23741fb9dffb38e1f737130fde71c44febac11 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 15:06:38 -0700 Subject: [PATCH 13/17] style: move new utils to end --- src/presets/vercel/utils.ts | 46 ++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index c2568818a6..e7bd8991fc 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -36,29 +36,6 @@ const ISR_SUFFIX = "-isr"; // Avoid using . as it can conflict with routing const SAFE_FS_CHAR_RE = /[^a-zA-Z0-9_.[\]/]/g; -/** - * Encodes a function path into a consumer name for queue/v2beta triggers. - * Mirrors the encoding from @vercel/build-utils sanitizeConsumerName(). - * @see https://github.com/vercel/vercel/blob/main/packages/build-utils/src/lambda.ts - */ -function sanitizeConsumerName(functionPath: string): string { - let result = ""; - for (const char of functionPath) { - if (char === "_") { - result += "__"; - } else if (char === "/") { - result += "_S"; - } else if (char === ".") { - result += "_D"; - } else if (/[A-Za-z0-9-]/.test(char)) { - result += char; - } else { - result += "_" + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0"); - } - } - return result; -} - function getSystemNodeVersion() { const systemNodeVersion = Number.parseInt(process.versions.node.split(".")[0]); @@ -620,6 +597,29 @@ function normalizeRouteDest(route: string) { ); } +/** + * Encodes a function path into a consumer name for queue/v2beta triggers. + * Mirrors the encoding from @vercel/build-utils sanitizeConsumerName(). + * @see https://github.com/vercel/vercel/blob/main/packages/build-utils/src/lambda.ts + */ +function sanitizeConsumerName(functionPath: string): string { + let result = ""; + for (const char of functionPath) { + if (char === "_") { + result += "__"; + } else if (char === "/") { + result += "_S"; + } else if (char === ".") { + result += "_D"; + } else if (/[A-Za-z0-9-]/.test(char)) { + result += char; + } else { + result += "_" + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0"); + } + } + return result; +} + async function createFunctionDirWithCustomConfig( funcDir: string, serverDir: string, From 5355a5be346d28024c3fbe864da3925d1bdb26ed Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 15:53:13 -0700 Subject: [PATCH 14/17] test: tidy snapshot --- test/presets/vercel.test.ts | 86 +++---------------------------------- 1 file changed, 5 insertions(+), 81 deletions(-) diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index f1253483ed..b613f89c11 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -409,7 +409,7 @@ describe("nitro:preset:vercel:web", async () => { items.push(`${dirname}/${entry.name}`); } else if (entry.isSymbolicLink()) { items.push(`${dirname}/${entry.name} (symlink)`); - } else if (/_\/|_.+|node_modules/.test(entry.name)) { + } else if (/_\/|_.+|node_modules/.test(entry.name) || entry.name.endsWith(".func")) { items.push(`${dirname}/${entry.name}`); } else if (entry.isDirectory()) { items.push(...(await walkDir(join(path, entry.name))).map((i) => `${dirname}/${i}`)); @@ -432,47 +432,9 @@ describe("nitro:preset:vercel:web", async () => { "functions/_vercel", "functions/api/cached.func (symlink)", "functions/api/db.func (symlink)", - "functions/api/echo.func/.vc-config.json", - "functions/api/echo.func/_...name_.mjs", - "functions/api/echo.func/_...name_.mjs.map", - "functions/api/echo.func/_...param_.mjs", - "functions/api/echo.func/_...param_.mjs.map", - "functions/api/echo.func/_...slug_.mjs", - "functions/api/echo.func/_...slug_.mjs.map", - "functions/api/echo.func/_chunks", - "functions/api/echo.func/_id_.mjs", - "functions/api/echo.func/_id_.mjs.map", - "functions/api/echo.func/_libs", - "functions/api/echo.func/_routes", - "functions/api/echo.func/_tasks", - "functions/api/echo.func/_test-id_.mjs", - "functions/api/echo.func/_test-id_.mjs.map", - "functions/api/echo.func/_virtual", - "functions/api/echo.func/index.mjs", - "functions/api/echo.func/index.mjs.map", - "functions/api/echo.func/node_modules", - "functions/api/echo.func/package.json", + "functions/api/echo.func", "functions/api/headers.func (symlink)", - "functions/api/hello.func/.vc-config.json", - "functions/api/hello.func/_...name_.mjs", - "functions/api/hello.func/_...name_.mjs.map", - "functions/api/hello.func/_...param_.mjs", - "functions/api/hello.func/_...param_.mjs.map", - "functions/api/hello.func/_...slug_.mjs", - "functions/api/hello.func/_...slug_.mjs.map", - "functions/api/hello.func/_chunks", - "functions/api/hello.func/_id_.mjs", - "functions/api/hello.func/_id_.mjs.map", - "functions/api/hello.func/_libs", - "functions/api/hello.func/_routes", - "functions/api/hello.func/_tasks", - "functions/api/hello.func/_test-id_.mjs", - "functions/api/hello.func/_test-id_.mjs.map", - "functions/api/hello.func/_virtual", - "functions/api/hello.func/index.mjs", - "functions/api/hello.func/index.mjs.map", - "functions/api/hello.func/node_modules", - "functions/api/hello.func/package.json", + "functions/api/hello.func", "functions/api/hey.func (symlink)", "functions/api/kebab.func (symlink)", "functions/api/meta/test.func (symlink)", @@ -512,47 +474,9 @@ describe("nitro:preset:vercel:web", async () => { "functions/rules/_/noncached/cached-isr.prerender-config.json", "functions/rules/isr-ttl/[...]-isr.func (symlink)", "functions/rules/isr-ttl/[...]-isr.prerender-config.json", - "functions/rules/isr/[...]-isr.func/.vc-config.json", - "functions/rules/isr/[...]-isr.func/_...name_.mjs", - "functions/rules/isr/[...]-isr.func/_...name_.mjs.map", - "functions/rules/isr/[...]-isr.func/_...param_.mjs", - "functions/rules/isr/[...]-isr.func/_...param_.mjs.map", - "functions/rules/isr/[...]-isr.func/_...slug_.mjs", - "functions/rules/isr/[...]-isr.func/_...slug_.mjs.map", - "functions/rules/isr/[...]-isr.func/_chunks", - "functions/rules/isr/[...]-isr.func/_id_.mjs", - "functions/rules/isr/[...]-isr.func/_id_.mjs.map", - "functions/rules/isr/[...]-isr.func/_libs", - "functions/rules/isr/[...]-isr.func/_routes", - "functions/rules/isr/[...]-isr.func/_tasks", - "functions/rules/isr/[...]-isr.func/_test-id_.mjs", - "functions/rules/isr/[...]-isr.func/_test-id_.mjs.map", - "functions/rules/isr/[...]-isr.func/_virtual", - "functions/rules/isr/[...]-isr.func/index.mjs", - "functions/rules/isr/[...]-isr.func/index.mjs.map", - "functions/rules/isr/[...]-isr.func/node_modules", - "functions/rules/isr/[...]-isr.func/package.json", + "functions/rules/isr/[...]-isr.func", "functions/rules/isr/[...]-isr.prerender-config.json", - "functions/rules/isr/[...].func/.vc-config.json", - "functions/rules/isr/[...].func/_...name_.mjs", - "functions/rules/isr/[...].func/_...name_.mjs.map", - "functions/rules/isr/[...].func/_...param_.mjs", - "functions/rules/isr/[...].func/_...param_.mjs.map", - "functions/rules/isr/[...].func/_...slug_.mjs", - "functions/rules/isr/[...].func/_...slug_.mjs.map", - "functions/rules/isr/[...].func/_chunks", - "functions/rules/isr/[...].func/_id_.mjs", - "functions/rules/isr/[...].func/_id_.mjs.map", - "functions/rules/isr/[...].func/_libs", - "functions/rules/isr/[...].func/_routes", - "functions/rules/isr/[...].func/_tasks", - "functions/rules/isr/[...].func/_test-id_.mjs", - "functions/rules/isr/[...].func/_test-id_.mjs.map", - "functions/rules/isr/[...].func/_virtual", - "functions/rules/isr/[...].func/index.mjs", - "functions/rules/isr/[...].func/index.mjs.map", - "functions/rules/isr/[...].func/node_modules", - "functions/rules/isr/[...].func/package.json", + "functions/rules/isr/[...].func", "functions/rules/swr-ttl/[...]-isr.func (symlink)", "functions/rules/swr-ttl/[...]-isr.prerender-config.json", "functions/rules/swr/[...]-isr.func (symlink)", From c55c0e2763ed3757a0dfbebb0d8753df892d9828 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sat, 28 Mar 2026 00:40:50 +0100 Subject: [PATCH 15/17] fix avoid writing extra fn --- src/presets/vercel/utils.ts | 8 ++++++++ test/presets/vercel.test.ts | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index e7bd8991fc..c5de9d331b 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -86,6 +86,7 @@ export async function generateFunctionFiles(nitro: Nitro) { } // Write ISR functions + const isrFuncDirs = new Set(); for (const [key, value] of Object.entries(nitro.options.routeRules)) { if (!value.isr) { continue; @@ -100,6 +101,9 @@ export async function generateFunctionFiles(nitro: Nitro) { const matchData = routeFuncRouter?.match("", key); if (matchData) { + isrFuncDirs.add( + resolve(nitro.options.output.serverDir, "..", normalizeRouteDest(key) + ".func") + ); await createFunctionDirWithCustomConfig( funcPrefix + ".func", nitro.options.output.serverDir, @@ -131,6 +135,10 @@ export async function generateFunctionFiles(nitro: Nitro) { "..", normalizeRouteDest(pattern) + ".func" ); + // Skip if ISR already created a custom config function for this route + if (isrFuncDirs.has(funcDir)) { + continue; + } await createFunctionDirWithCustomConfig( funcDir, nitro.options.output.serverDir, diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index b613f89c11..c9a537c4a1 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -476,7 +476,6 @@ describe("nitro:preset:vercel:web", async () => { "functions/rules/isr-ttl/[...]-isr.prerender-config.json", "functions/rules/isr/[...]-isr.func", "functions/rules/isr/[...]-isr.prerender-config.json", - "functions/rules/isr/[...].func", "functions/rules/swr-ttl/[...]-isr.func (symlink)", "functions/rules/swr-ttl/[...]-isr.prerender-config.json", "functions/rules/swr/[...]-isr.func (symlink)", From 64492d91a94b7641a2ed7bc0cca8f77a5f5a7551 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sat, 28 Mar 2026 00:49:26 +0100 Subject: [PATCH 16/17] update fixture --- test/presets/cloudflare-pages.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/presets/cloudflare-pages.test.ts b/test/presets/cloudflare-pages.test.ts index 9f833bd154..80956cd773 100644 --- a/test/presets/cloudflare-pages.test.ts +++ b/test/presets/cloudflare-pages.test.ts @@ -51,6 +51,9 @@ describe.skipIf(isWindows)("nitro:preset:cloudflare-pages", async () => { "/foo.css", "/foo.js", "/json-string", + "/nitro.json.br", + "/nitro.json.gz", + "/nitro.json.zst", "/prerender", "/prerender-custom", "/_scalar/index.html.br", From 0ad35641ae2032149e58c4c580d49f6cdd97be62 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sat, 28 Mar 2026 00:52:11 +0100 Subject: [PATCH 17/17] rename to functionRules --- docs/2.deploy/20.providers/vercel.md | 4 ++-- src/presets/vercel/types.ts | 4 ++-- src/presets/vercel/utils.ts | 22 +++++++++++----------- test/fixture/nitro.config.ts | 2 +- test/presets/vercel.test.ts | 10 +++++----- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index 586ba05275..305438101f 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -57,7 +57,7 @@ Alternatively, Nitro also detects Bun automatically if you specify a `bunVersion ## Per-route function configuration -Use `vercel.routeFunctionConfig` to override [serverless function settings](https://vercel.com/docs/build-output-api/primitives#serverless-function-configuration) for specific routes. Each key is a route pattern and its value is a partial function configuration object that gets merged with the base `vercel.functions` config. Note: array properties (e.g., `regions`) from route config will replace the base config arrays rather than merging them. +Use `vercel.functionRules` to override [serverless function settings](https://vercel.com/docs/build-output-api/primitives#serverless-function-configuration) for specific routes. Each key is a route pattern and its value is a partial function configuration object that gets merged with the base `vercel.functions` config. Note: array properties (e.g., `regions`) from route config will replace the base config arrays rather than merging them. This is useful when certain routes need different resource limits, regions, or features like [Vercel Queues triggers](https://vercel.com/docs/queues). @@ -66,7 +66,7 @@ import { defineNitroConfig } from "nitro/config"; export default defineNitroConfig({ vercel: { - routeFunctionConfig: { + functionRules: { "/api/heavy-computation": { maxDuration: 800, memory: 4096, diff --git a/src/presets/vercel/types.ts b/src/presets/vercel/types.ts index 7f92d6fd8b..e86904c254 100644 --- a/src/presets/vercel/types.ts +++ b/src/presets/vercel/types.ts @@ -156,7 +156,7 @@ export interface VercelOptions { * * @example * ```ts - * routeFunctionConfig: { + * functionRules: { * '/api/my-slow-routes/**': { maxDuration: 3600 }, * '/api/queues/fulfill-order': { * experimentalTriggers: [{ type: 'queue/v2beta', topic: 'orders' }], @@ -164,7 +164,7 @@ export interface VercelOptions { * } * ``` */ - routeFunctionConfig?: Record; + functionRules?: Record; } /** diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index c5de9d331b..b25382561a 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -64,20 +64,20 @@ export async function generateFunctionFiles(nitro: Nitro) { nitro.logger.warn( "`experimentalTriggers` on the base `vercel.functions` config applies to the catch-all function and is likely not what you want. " + "Routes with queue triggers are not accesible on the web." + - "Use `vercel.routeFunctionConfig` to attach triggers to specific routes instead." + "Use `vercel.functionRules` to attach triggers to specific routes instead." ); } const functionConfigPath = resolve(nitro.options.output.serverDir, ".vc-config.json"); await writeFile(functionConfigPath, JSON.stringify(baseFunctionConfig, null, 2)); - const routeFunctionConfig = nitro.options.vercel?.routeFunctionConfig; - const hasRouteFunctionConfig = routeFunctionConfig && Object.keys(routeFunctionConfig).length > 0; + const functionRules = nitro.options.vercel?.functionRules; + const hasfunctionRules = functionRules && Object.keys(functionRules).length > 0; let routeFuncRouter: Router | undefined; - if (hasRouteFunctionConfig) { + if (hasfunctionRules) { routeFuncRouter = new Router(); routeFuncRouter._update( - Object.entries(routeFunctionConfig).map(([route, data]) => ({ + Object.entries(functionRules).map(([route, data]) => ({ route, method: "", data, @@ -126,10 +126,10 @@ export async function generateFunctionFiles(nitro: Nitro) { ); } - // Write routeFunctionConfig custom function directories + // Write functionRules custom function directories const createdFuncDirs = new Set(); - if (hasRouteFunctionConfig) { - for (const [pattern, overrides] of Object.entries(routeFunctionConfig!)) { + if (hasfunctionRules) { + for (const [pattern, overrides] of Object.entries(functionRules!)) { const funcDir = resolve( nitro.options.output.serverDir, "..", @@ -164,7 +164,7 @@ export async function generateFunctionFiles(nitro: Nitro) { const funcPrefix = resolve(nitro.options.output.serverDir, "..", route.dest); const funcDir = funcPrefix + ".func"; - // Skip if already created by routeFunctionConfig + // Skip if already created by functionRules if (createdFuncDirs.has(funcDir)) { continue; } @@ -360,8 +360,8 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { }; }), // Route function config routes - ...(nitro.options.vercel?.routeFunctionConfig - ? Object.keys(nitro.options.vercel.routeFunctionConfig).map((pattern) => ({ + ...(nitro.options.vercel?.functionRules + ? Object.keys(nitro.options.vercel.functionRules).map((pattern) => ({ src: joinURL(nitro.options.baseURL, normalizeRouteSrc(pattern)), dest: withLeadingSlash(normalizeRouteDest(pattern)), })) diff --git a/test/fixture/nitro.config.ts b/test/fixture/nitro.config.ts index ba3bd84d67..197a9da1de 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -5,7 +5,7 @@ import { existsSync } from "node:fs"; export default defineConfig({ vercel: { - routeFunctionConfig: { + functionRules: { "/api/hello": { maxDuration: 100, }, diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index c9a537c4a1..d6327043a0 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -491,14 +491,14 @@ describe("nitro:preset:vercel:web", async () => { `); }); - it("should create custom function directory for routeFunctionConfig (not symlink)", async () => { + it("should create custom function directory for functionRules (not symlink)", async () => { const funcDir = resolve(ctx.outDir, "functions/api/hello.func"); const stat = await fsp.lstat(funcDir); expect(stat.isDirectory()).toBe(true); expect(stat.isSymbolicLink()).toBe(false); }); - it("should write merged .vc-config.json with routeFunctionConfig overrides", async () => { + it("should write merged .vc-config.json with functionRules overrides", async () => { const config = await fsp .readFile(resolve(ctx.outDir, "functions/api/hello.func/.vc-config.json"), "utf8") .then((r) => JSON.parse(r)); @@ -508,7 +508,7 @@ describe("nitro:preset:vercel:web", async () => { expect(config.supportsResponseStreaming).toBe(true); }); - it("should write routeFunctionConfig with arbitrary fields", async () => { + it("should write functionRules with arbitrary fields", async () => { const config = await fsp .readFile(resolve(ctx.outDir, "functions/api/echo.func/.vc-config.json"), "utf8") .then((r) => JSON.parse(r)); @@ -518,13 +518,13 @@ describe("nitro:preset:vercel:web", async () => { expect(config.handler).toBe("index.mjs"); }); - it("should copy files inside routeFunctionConfig directory from __server.func", async () => { + it("should copy files inside functionRules directory from __server.func", async () => { const funcDir = resolve(ctx.outDir, "functions/api/hello.func"); const indexStat = await fsp.lstat(resolve(funcDir, "index.mjs")); expect(indexStat.isFile()).toBe(true); }); - it("should keep base __server.func without routeFunctionConfig overrides", async () => { + it("should keep base __server.func without functionRules overrides", async () => { const config = await fsp .readFile(resolve(ctx.outDir, "functions/__server.func/.vc-config.json"), "utf8") .then((r) => JSON.parse(r));