diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index e835edf324..586ba05275 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. 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. 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..6d443f12f2 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: joinURL(nitro.options.baseURL, normalizeRouteSrc(pattern)), + dest: withLeadingSlash(normalizeRouteDest(pattern)), + })) + : []), // Observability routes ...(o11Routes || []).map((route) => ({ src: joinURL(nitro.options.baseURL, route.src), @@ -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)[key] = value; + } + } + await writeFile(resolve(funcDir, ".vc-config.json"), JSON.stringify(mergedConfig, null, 2)); +} + async function writePrerenderConfig( filename: string, isrConfig: NitroRouteRules["isr"], 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 e61e808760..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"); + }); } ); });