Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
29 changes: 29 additions & 0 deletions docs/2.deploy/20.providers/vercel.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,35 @@ Alternatively, Nitro also detects Bun automatically if you specify a `bunVersion
}
```

## Per-route function configuration

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

```ts [nitro.config.ts]
import { defineNitroConfig } from "nitro/config";

export default defineNitroConfig({
vercel: {
functionRules: {
"/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.
Expand Down
18 changes: 18 additions & 0 deletions src/presets/vercel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* functionRules: {
* '/api/my-slow-routes/**': { maxDuration: 3600 },
* '/api/queues/fulfill-order': {
* experimentalTriggers: [{ type: 'queue/v2beta', topic: 'orders' }],
* },
* }
* ```
*/
functionRules?: Record<string, VercelServerlessFunctionConfig>;
}

/**
Expand Down
176 changes: 162 additions & 14 deletions src/presets/vercel/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { Router } from "../../routing.ts";
import { joinURL, withLeadingSlash, withoutLeadingSlash } from "ufo";
import type {
PrerenderFunctionConfig,
Expand Down Expand Up @@ -48,17 +49,44 @@ 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));

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.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 functionRules = nitro.options.vercel?.functionRules;
const hasfunctionRules = functionRules && Object.keys(functionRules).length > 0;
let routeFuncRouter: Router<VercelServerlessFunctionConfig> | undefined;
if (hasfunctionRules) {
routeFuncRouter = new Router<VercelServerlessFunctionConfig>();
routeFuncRouter._update(
Object.entries(functionRules).map(([route, data]) => ({
route,
method: "",
data,
}))
);
}

// Write ISR functions
const isrFuncDirs = new Set<string>();
for (const [key, value] of Object.entries(nitro.options.routeRules)) {
if (!value.isr) {
continue;
Expand All @@ -70,18 +98,58 @@ 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 matchData = routeFuncRouter?.match("", key);
if (matchData) {
isrFuncDirs.add(
resolve(nitro.options.output.serverDir, "..", normalizeRouteDest(key) + ".func")
);
await createFunctionDirWithCustomConfig(
funcPrefix + ".func",
nitro.options.output.serverDir,
baseFunctionConfig,
matchData,
normalizeRouteDest(key) + ISR_SUFFIX
);
} else {
await fsp.symlink(
"./" + relative(dirname(funcPrefix), nitro.options.output.serverDir),
funcPrefix + ".func",
"junction"
);
}

await writePrerenderConfig(
funcPrefix + ".prerender-config.json",
value.isr,
nitro.options.vercel?.config?.bypassToken
);
}

// Write functionRules custom function directories
const createdFuncDirs = new Set<string>();
if (hasfunctionRules) {
for (const [pattern, overrides] of Object.entries(functionRules!)) {
const funcDir = resolve(
nitro.options.output.serverDir,
"..",
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,
baseFunctionConfig,
overrides,
normalizeRouteDest(pattern)
);
createdFuncDirs.add(funcDir);
}
}

// Write observability routes
if (o11Routes.length === 0) {
return;
Expand All @@ -94,12 +162,30 @@ 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 functionRules
if (createdFuncDirs.has(funcDir)) {
continue;
}

const matchData = routeFuncRouter?.match("", route.src);
if (matchData) {
await createFunctionDirWithCustomConfig(
funcDir,
nitro.options.output.serverDir,
baseFunctionConfig,
matchData,
route.dest
);
} else {
await fsp.mkdir(dirname(funcPrefix), { recursive: true });
await fsp.symlink(
"./" + relative(dirname(funcPrefix), nitro.options.output.serverDir),
funcDir,
"junction"
);
}
}
}

Expand Down Expand Up @@ -273,6 +359,13 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) {
),
};
}),
// Route function config routes
...(nitro.options.vercel?.functionRules
? Object.keys(nitro.options.vercel.functionRules).map((pattern) => ({
src: joinURL(nitro.options.baseURL, normalizeRouteSrc(pattern)),
dest: withLeadingSlash(normalizeRouteDest(pattern)),
}))
: []),
Comment on lines +362 to +368
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Route-function routes should be specificity-ordered and baseURL-aware.

Current emission order depends on object key insertion, so wildcard patterns can shadow specific ones. Also, these src routes are not prefixed with nitro.options.baseURL, unlike observability routes.

πŸ’‘ Proposed fix
+  const routeFunctionPatterns = nitro.options.vercel?.routeFunctionConfig
+    ? Object.keys(nitro.options.vercel.routeFunctionConfig).sort(
+        (a, b) => b.split(/\/(?!\*)/).length - a.split(/\/(?!\*)/).length
+      )
+    : [];
+
   config.routes!.push(
@@
-    ...(nitro.options.vercel?.routeFunctionConfig
-      ? Object.keys(nitro.options.vercel.routeFunctionConfig).map((pattern) => ({
-          src: normalizeRouteSrc(pattern),
+    ...routeFunctionPatterns.map((pattern) => ({
+          src: joinURL(nitro.options.baseURL, normalizeRouteSrc(pattern)),
           dest: withLeadingSlash(normalizeRouteDest(pattern)),
-        }))
-      : []),
+        })),
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/presets/vercel/utils.ts` around lines 336 - 342, The route-function
config emission currently iterates
Object.keys(nitro.options.vercel.routeFunctionConfig) which yields
nondeterministic order and can let wildcards shadow specific routes; update the
logic that maps over nitro.options.vercel.routeFunctionConfig to first sort the
route patterns by specificity (e.g., more static segments and
fewer/wildcards/params first) so specific patterns come before wildcards, and
ensure the generated src uses nitro.options.baseURL as a prefix (apply the same
baseURL handling used for observability routes) before calling
normalizeRouteSrc; keep dest generation via
withLeadingSlash(normalizeRouteDest(pattern)) unchanged.

// Observability routes
...(o11Routes || []).map((route) => ({
src: joinURL(nitro.options.baseURL, route.src),
Expand Down Expand Up @@ -512,6 +605,61 @@ 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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you tried hardlinks?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Briefly but had complexities with symlinks in node_modules etc. since only files can be hard linked it seems. I don't think many functions would have overrides and copying reduces a lot of complexity. I may revisit getting symlinks working in Vercel CI later again though.

funcDir: string,
serverDir: string,
baseFunctionConfig: VercelServerlessFunctionConfig,
overrides: VercelServerlessFunctionConfig,
functionPath: string
) {
// 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)) {
(mergedConfig as Record<string, unknown>)[key] = value;
}
}

// Auto-derive consumer for queue/v2beta triggers
const triggers = mergedConfig.experimentalTriggers;
if (Array.isArray(triggers)) {
for (const trigger of triggers as Array<Record<string, unknown>>) {
if (trigger.type === "queue/v2beta" && !trigger.consumer) {
trigger.consumer = sanitizeConsumerName(functionPath);
}
}
}

await writeFile(resolve(funcDir, ".vc-config.json"), JSON.stringify(mergedConfig, null, 2));
}

async function writePrerenderConfig(
filename: string,
isrConfig: NitroRouteRules["isr"],
Expand Down
13 changes: 13 additions & 0 deletions test/fixture/nitro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ import { dirname, resolve } from "node:path";
import { existsSync } from "node:fs";

export default defineConfig({
vercel: {
functionRules: {
"/api/hello": {
maxDuration: 100,
},
"/api/echo": {
experimentalTriggers: [{ type: "queue/v2beta", topic: "orders" }],
},
"/rules/isr/**": {
regions: ["lhr1", "cdg1"],
},
},
},
compressPublicAssets: true,
compatibilityDate: "latest",
serverDir: "server",
Expand Down
3 changes: 3 additions & 0 deletions test/presets/cloudflare-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading