Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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.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.
Comment thread
RihanArfan marked this conversation as resolved.
Outdated

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>;
Comment thread
pi0 marked this conversation as resolved.
Outdated
}

/**
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 +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 +579,30 @@ function normalizeRouteDest(route: string) {
);
}

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
) {
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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

async function writePrerenderConfig(
filename: string,
isrConfig: NitroRouteRules["isr"],
Expand Down
72 changes: 72 additions & 0 deletions test/presets/vercel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
Loading