Skip to content

Commit 589e8ad

Browse files
RihanArfancoderabbitai[bot]pi0
authored
feat(vercel): allow overriding function config by route (#4124)
* feat(vercel): allow overriding function config by route * fix: factor in base url with function route matching * fix: replace arrays rather than merge * docs(vercel): function config overrides * Update docs/2.deploy/20.providers/vercel.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * test(vercel): move to main fixture * feat(vercel): generate consumer for queue triggers * fix(vercel): copy function output instead of symlinking when config overridden * test: update fixture * test: copy function output instead of symlinking when config overridden * test: copy function output instead of symlinking when config overridden * refactor: use nitro routing * style: move new utils to end * test: tidy snapshot * fix avoid writing extra fn * update fixture * rename to functionRules --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Pooya Parsa <pooya@pi0.io>
1 parent 2888e8f commit 589e8ad

6 files changed

Lines changed: 282 additions & 18 deletions

File tree

docs/2.deploy/20.providers/vercel.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,35 @@ Alternatively, Nitro also detects Bun automatically if you specify a `bunVersion
5555
}
5656
```
5757

58+
## Per-route function configuration
59+
60+
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.
61+
62+
This is useful when certain routes need different resource limits, regions, or features like [Vercel Queues triggers](https://vercel.com/docs/queues).
63+
64+
```ts [nitro.config.ts]
65+
import { defineNitroConfig } from "nitro/config";
66+
67+
export default defineNitroConfig({
68+
vercel: {
69+
functionRules: {
70+
"/api/heavy-computation": {
71+
maxDuration: 800,
72+
memory: 4096,
73+
},
74+
"/api/regional": {
75+
regions: ["lhr1", "cdg1"],
76+
},
77+
"/api/queues/process-order": {
78+
experimentalTriggers: [{ type: "queue/v2beta", topic: "orders" }],
79+
},
80+
},
81+
},
82+
});
83+
```
84+
85+
Route patterns support wildcards via [rou3](https://github.com/h3js/rou3) matching (e.g., `/api/slow/**` matches all routes under `/api/slow/`).
86+
5887
## Proxy route rules
5988

6089
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.

src/presets/vercel/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,24 @@ export interface VercelOptions {
147147
* @see https://vercel.com/docs/cron-jobs
148148
*/
149149
cronHandlerRoute?: string;
150+
151+
/**
152+
* Per-route function configuration overrides.
153+
*
154+
* Keys are route patterns (e.g., `/api/queues/*`, `/api/slow-routes/**`).
155+
* Values are partial {@link VercelServerlessFunctionConfig} objects.
156+
*
157+
* @example
158+
* ```ts
159+
* functionRules: {
160+
* '/api/my-slow-routes/**': { maxDuration: 3600 },
161+
* '/api/queues/fulfill-order': {
162+
* experimentalTriggers: [{ type: 'queue/v2beta', topic: 'orders' }],
163+
* },
164+
* }
165+
* ```
166+
*/
167+
functionRules?: Record<string, VercelServerlessFunctionConfig>;
150168
}
151169

152170
/**

src/presets/vercel/utils.ts

Lines changed: 162 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { defu } from "defu";
33
import { writeFile } from "../_utils/fs.ts";
44
import type { Nitro, NitroRouteRules } from "nitro/types";
55
import { dirname, relative, resolve } from "pathe";
6+
import { Router } from "../../routing.ts";
67
import { joinURL, withLeadingSlash, withoutLeadingSlash } from "ufo";
78
import type {
89
PrerenderFunctionConfig,
@@ -48,17 +49,44 @@ export async function generateFunctionFiles(nitro: Nitro) {
4849
const buildConfig = generateBuildConfig(nitro, o11Routes);
4950
await writeFile(buildConfigPath, JSON.stringify(buildConfig, null, 2));
5051

51-
const functionConfigPath = resolve(nitro.options.output.serverDir, ".vc-config.json");
52-
const functionConfig: VercelServerlessFunctionConfig = {
52+
const baseFunctionConfig: VercelServerlessFunctionConfig = {
5353
handler: "index.mjs",
5454
launcherType: "Nodejs",
5555
shouldAddHelpers: false,
5656
supportsResponseStreaming: true,
5757
...nitro.options.vercel?.functions,
5858
};
59-
await writeFile(functionConfigPath, JSON.stringify(functionConfig, null, 2));
59+
60+
if (
61+
Array.isArray(baseFunctionConfig.experimentalTriggers) &&
62+
baseFunctionConfig.experimentalTriggers.length > 0
63+
) {
64+
nitro.logger.warn(
65+
"`experimentalTriggers` on the base `vercel.functions` config applies to the catch-all function and is likely not what you want. " +
66+
"Routes with queue triggers are not accesible on the web." +
67+
"Use `vercel.functionRules` to attach triggers to specific routes instead."
68+
);
69+
}
70+
71+
const functionConfigPath = resolve(nitro.options.output.serverDir, ".vc-config.json");
72+
await writeFile(functionConfigPath, JSON.stringify(baseFunctionConfig, null, 2));
73+
74+
const functionRules = nitro.options.vercel?.functionRules;
75+
const hasfunctionRules = functionRules && Object.keys(functionRules).length > 0;
76+
let routeFuncRouter: Router<VercelServerlessFunctionConfig> | undefined;
77+
if (hasfunctionRules) {
78+
routeFuncRouter = new Router<VercelServerlessFunctionConfig>();
79+
routeFuncRouter._update(
80+
Object.entries(functionRules).map(([route, data]) => ({
81+
route,
82+
method: "",
83+
data,
84+
}))
85+
);
86+
}
6087

6188
// Write ISR functions
89+
const isrFuncDirs = new Set<string>();
6290
for (const [key, value] of Object.entries(nitro.options.routeRules)) {
6391
if (!value.isr) {
6492
continue;
@@ -70,18 +98,58 @@ export async function generateFunctionFiles(nitro: Nitro) {
7098
normalizeRouteDest(key) + ISR_SUFFIX
7199
);
72100
await fsp.mkdir(dirname(funcPrefix), { recursive: true });
73-
await fsp.symlink(
74-
"./" + relative(dirname(funcPrefix), nitro.options.output.serverDir),
75-
funcPrefix + ".func",
76-
"junction"
77-
);
101+
102+
const matchData = routeFuncRouter?.match("", key);
103+
if (matchData) {
104+
isrFuncDirs.add(
105+
resolve(nitro.options.output.serverDir, "..", normalizeRouteDest(key) + ".func")
106+
);
107+
await createFunctionDirWithCustomConfig(
108+
funcPrefix + ".func",
109+
nitro.options.output.serverDir,
110+
baseFunctionConfig,
111+
matchData,
112+
normalizeRouteDest(key) + ISR_SUFFIX
113+
);
114+
} else {
115+
await fsp.symlink(
116+
"./" + relative(dirname(funcPrefix), nitro.options.output.serverDir),
117+
funcPrefix + ".func",
118+
"junction"
119+
);
120+
}
121+
78122
await writePrerenderConfig(
79123
funcPrefix + ".prerender-config.json",
80124
value.isr,
81125
nitro.options.vercel?.config?.bypassToken
82126
);
83127
}
84128

129+
// Write functionRules custom function directories
130+
const createdFuncDirs = new Set<string>();
131+
if (hasfunctionRules) {
132+
for (const [pattern, overrides] of Object.entries(functionRules!)) {
133+
const funcDir = resolve(
134+
nitro.options.output.serverDir,
135+
"..",
136+
normalizeRouteDest(pattern) + ".func"
137+
);
138+
// Skip if ISR already created a custom config function for this route
139+
if (isrFuncDirs.has(funcDir)) {
140+
continue;
141+
}
142+
await createFunctionDirWithCustomConfig(
143+
funcDir,
144+
nitro.options.output.serverDir,
145+
baseFunctionConfig,
146+
overrides,
147+
normalizeRouteDest(pattern)
148+
);
149+
createdFuncDirs.add(funcDir);
150+
}
151+
}
152+
85153
// Write observability routes
86154
if (o11Routes.length === 0) {
87155
return;
@@ -94,12 +162,30 @@ export async function generateFunctionFiles(nitro: Nitro) {
94162
continue; // #3563
95163
}
96164
const funcPrefix = resolve(nitro.options.output.serverDir, "..", route.dest);
97-
await fsp.mkdir(dirname(funcPrefix), { recursive: true });
98-
await fsp.symlink(
99-
"./" + relative(dirname(funcPrefix), nitro.options.output.serverDir),
100-
funcPrefix + ".func",
101-
"junction"
102-
);
165+
const funcDir = funcPrefix + ".func";
166+
167+
// Skip if already created by functionRules
168+
if (createdFuncDirs.has(funcDir)) {
169+
continue;
170+
}
171+
172+
const matchData = routeFuncRouter?.match("", route.src);
173+
if (matchData) {
174+
await createFunctionDirWithCustomConfig(
175+
funcDir,
176+
nitro.options.output.serverDir,
177+
baseFunctionConfig,
178+
matchData,
179+
route.dest
180+
);
181+
} else {
182+
await fsp.mkdir(dirname(funcPrefix), { recursive: true });
183+
await fsp.symlink(
184+
"./" + relative(dirname(funcPrefix), nitro.options.output.serverDir),
185+
funcDir,
186+
"junction"
187+
);
188+
}
103189
}
104190
}
105191

@@ -273,6 +359,13 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) {
273359
),
274360
};
275361
}),
362+
// Route function config routes
363+
...(nitro.options.vercel?.functionRules
364+
? Object.keys(nitro.options.vercel.functionRules).map((pattern) => ({
365+
src: joinURL(nitro.options.baseURL, normalizeRouteSrc(pattern)),
366+
dest: withLeadingSlash(normalizeRouteDest(pattern)),
367+
}))
368+
: []),
276369
// Observability routes
277370
...(o11Routes || []).map((route) => ({
278371
src: joinURL(nitro.options.baseURL, route.src),
@@ -512,6 +605,61 @@ function normalizeRouteDest(route: string) {
512605
);
513606
}
514607

608+
/**
609+
* Encodes a function path into a consumer name for queue/v2beta triggers.
610+
* Mirrors the encoding from @vercel/build-utils sanitizeConsumerName().
611+
* @see https://github.com/vercel/vercel/blob/main/packages/build-utils/src/lambda.ts
612+
*/
613+
function sanitizeConsumerName(functionPath: string): string {
614+
let result = "";
615+
for (const char of functionPath) {
616+
if (char === "_") {
617+
result += "__";
618+
} else if (char === "/") {
619+
result += "_S";
620+
} else if (char === ".") {
621+
result += "_D";
622+
} else if (/[A-Za-z0-9-]/.test(char)) {
623+
result += char;
624+
} else {
625+
result += "_" + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0");
626+
}
627+
}
628+
return result;
629+
}
630+
631+
async function createFunctionDirWithCustomConfig(
632+
funcDir: string,
633+
serverDir: string,
634+
baseFunctionConfig: VercelServerlessFunctionConfig,
635+
overrides: VercelServerlessFunctionConfig,
636+
functionPath: string
637+
) {
638+
// Copy the entire server directory instead of symlinking individual
639+
// entries. Vercel's build container preserves symlinks in the Lambda
640+
// zip, but symlinks pointing outside the .func directory break at
641+
// runtime because the target path doesn't exist on Lambda.
642+
await fsp.cp(serverDir, funcDir, { recursive: true });
643+
const mergedConfig = defu(overrides, baseFunctionConfig);
644+
for (const [key, value] of Object.entries(overrides)) {
645+
if (Array.isArray(value)) {
646+
(mergedConfig as Record<string, unknown>)[key] = value;
647+
}
648+
}
649+
650+
// Auto-derive consumer for queue/v2beta triggers
651+
const triggers = mergedConfig.experimentalTriggers;
652+
if (Array.isArray(triggers)) {
653+
for (const trigger of triggers as Array<Record<string, unknown>>) {
654+
if (trigger.type === "queue/v2beta" && !trigger.consumer) {
655+
trigger.consumer = sanitizeConsumerName(functionPath);
656+
}
657+
}
658+
}
659+
660+
await writeFile(resolve(funcDir, ".vc-config.json"), JSON.stringify(mergedConfig, null, 2));
661+
}
662+
515663
async function writePrerenderConfig(
516664
filename: string,
517665
isrConfig: NitroRouteRules["isr"],

test/fixture/nitro.config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ import { dirname, resolve } from "node:path";
44
import { existsSync } from "node:fs";
55

66
export default defineConfig({
7+
vercel: {
8+
functionRules: {
9+
"/api/hello": {
10+
maxDuration: 100,
11+
},
12+
"/api/echo": {
13+
experimentalTriggers: [{ type: "queue/v2beta", topic: "orders" }],
14+
},
15+
"/rules/isr/**": {
16+
regions: ["lhr1", "cdg1"],
17+
},
18+
},
19+
},
720
compressPublicAssets: true,
821
compatibilityDate: "latest",
922
serverDir: "server",

test/presets/cloudflare-pages.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ describe.skipIf(isWindows)("nitro:preset:cloudflare-pages", async () => {
5151
"/foo.css",
5252
"/foo.js",
5353
"/json-string",
54+
"/nitro.json.br",
55+
"/nitro.json.gz",
56+
"/nitro.json.zst",
5457
"/prerender",
5558
"/prerender-custom",
5659
"/_scalar/index.html.br",

0 commit comments

Comments
 (0)