Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.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).

```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.
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
* routeFunctionConfig: {
* '/api/my-slow-routes/**': { maxDuration: 3600 },
* '/api/queues/fulfill-order': {
* experimentalTriggers: [{ type: 'queue/v2beta', topic: 'orders' }],
* },
* }
* ```
*/
routeFunctionConfig?: Record<string, VercelServerlessFunctionConfig>;
Copy link
Member Author

Choose a reason for hiding this comment

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

@pi0 what should we call the key? I'm not satisfied with this, just used it as a placeholder for now. Also as it accepts a list of routes perhaps the key should be plural with routeFunctionsConfig if we decide not to change it to something else altogether?

}

/**
Expand Down
119 changes: 105 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 { createRouter, addRoute, findRoute } from "rou3";
import { joinURL, withLeadingSlash, withoutLeadingSlash } from "ufo";
import type {
PrerenderFunctionConfig,
Expand Down Expand Up @@ -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<typeof createRouter<VercelServerlessFunctionConfig>> | undefined;
if (hasRouteFunctionConfig) {
routeFuncRouter = createRouter<VercelServerlessFunctionConfig>();
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)) {
Expand All @@ -70,18 +82,49 @@ 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,
nitro.options.vercel?.config?.bypassToken
);
}

// Write routeFunctionConfig custom function directories
const createdFuncDirs = new Set<string>();
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;
Expand All @@ -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"
);
}
}
}

Expand Down Expand Up @@ -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: joinURL(nitro.options.baseURL, normalizeRouteSrc(pattern)),
dest: withLeadingSlash(normalizeRouteDest(pattern)),
}))
: []),
Comment on lines +336 to +342
Copy link

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 +579,30 @@ 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);
for (const [key, value] of Object.entries(overrides)) {
if (Array.isArray(value)) {
(mergedConfig as Record<string, unknown>)[key] = value;
}
}
await writeFile(resolve(funcDir, ".vc-config.json"), JSON.stringify(mergedConfig, null, 2));
}

async function writePrerenderConfig(
filename: string,
isrConfig: NitroRouteRules["isr"],
Expand Down
10 changes: 10 additions & 0 deletions test/fixture/nitro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
89 changes: 87 additions & 2 deletions test/presets/vercel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@
"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",
Expand Down Expand Up @@ -410,7 +418,7 @@
it("should generated expected functions", async () => {
const functionsDir = resolve(ctx.outDir, "functions");
const functionsFiles = await walkDir(functionsDir);
expect(functionsFiles).toMatchInlineSnapshot(`

Check failure on line 421 in test/presets/vercel.test.ts

View workflow job for this annotation

GitHub Actions / tests-rollup (windows-latest)

test/presets/vercel.test.ts > nitro:preset:vercel:web > should generated expected functions

Error: Snapshot `nitro:preset:vercel:web > should generated expected functions 1` mismatched - Expected + Received @@ -16,12 +16,10 @@ "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)", @@ -37,12 +35,10 @@ "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)", ❯ test/presets/vercel.test.ts:421:32

Check failure on line 421 in test/presets/vercel.test.ts

View workflow job for this annotation

GitHub Actions / tests-rolldown (windows-latest)

test/presets/vercel.test.ts > nitro:preset:vercel:web > should generated expected functions

Error: Snapshot `nitro:preset:vercel:web > should generated expected functions 1` mismatched - Expected + Received @@ -16,12 +16,10 @@ "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)", @@ -37,12 +35,10 @@ "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)", ❯ test/presets/vercel.test.ts:421:32
[
"functions/500.func (symlink)",
"functions/__server.func",
Expand All @@ -420,9 +428,47 @@
"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)",
Expand Down Expand Up @@ -478,6 +524,45 @@
]
`);
});

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");
});
}
);
});
Expand Down
Loading