diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index e835edf324..305438101f 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.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. diff --git a/src/presets/vercel/types.ts b/src/presets/vercel/types.ts index 4c8bd608f2..e86904c254 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 + * functionRules: { + * '/api/my-slow-routes/**': { maxDuration: 3600 }, + * '/api/queues/fulfill-order': { + * experimentalTriggers: [{ type: 'queue/v2beta', topic: 'orders' }], + * }, + * } + * ``` + */ + functionRules?: Record; } /** diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index 02447c8a0e..b25382561a 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 { Router } from "../../routing.ts"; import { joinURL, withLeadingSlash, withoutLeadingSlash } from "ufo"; import type { PrerenderFunctionConfig, @@ -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 | undefined; + if (hasfunctionRules) { + routeFuncRouter = new Router(); + routeFuncRouter._update( + Object.entries(functionRules).map(([route, data]) => ({ + route, + method: "", + data, + })) + ); + } // Write ISR functions + const isrFuncDirs = new Set(); for (const [key, value] of Object.entries(nitro.options.routeRules)) { if (!value.isr) { continue; @@ -70,11 +98,27 @@ 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, @@ -82,6 +126,30 @@ export async function generateFunctionFiles(nitro: Nitro) { ); } + // Write functionRules custom function directories + const createdFuncDirs = new Set(); + 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; @@ -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" + ); + } } } @@ -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)), + })) + : []), // Observability routes ...(o11Routes || []).map((route) => ({ src: joinURL(nitro.options.baseURL, route.src), @@ -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( + 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)[key] = value; + } + } + + // Auto-derive consumer for queue/v2beta triggers + const triggers = mergedConfig.experimentalTriggers; + if (Array.isArray(triggers)) { + for (const trigger of triggers as Array>) { + 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"], diff --git a/test/fixture/nitro.config.ts b/test/fixture/nitro.config.ts index 53fdb7b36b..197a9da1de 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -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", diff --git a/test/presets/cloudflare-pages.test.ts b/test/presets/cloudflare-pages.test.ts index 9f833bd154..80956cd773 100644 --- a/test/presets/cloudflare-pages.test.ts +++ b/test/presets/cloudflare-pages.test.ts @@ -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", diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index e61e808760..d6327043a0 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -167,6 +167,18 @@ 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": "/rules/isr/[...]", + "src": "/rules/isr/(?:.*)", + }, { "dest": "/wasm/static-import", "src": "/wasm/static-import", @@ -397,7 +409,7 @@ describe("nitro:preset:vercel:web", async () => { items.push(`${dirname}/${entry.name}`); } else if (entry.isSymbolicLink()) { items.push(`${dirname}/${entry.name} (symlink)`); - } else if (/_\/|_.+|node_modules/.test(entry.name)) { + } else if (/_\/|_.+|node_modules/.test(entry.name) || entry.name.endsWith(".func")) { items.push(`${dirname}/${entry.name}`); } else if (entry.isDirectory()) { items.push(...(await walkDir(join(path, entry.name))).map((i) => `${dirname}/${i}`)); @@ -420,9 +432,9 @@ 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", "functions/api/headers.func (symlink)", - "functions/api/hello.func (symlink)", + "functions/api/hello.func", "functions/api/hey.func (symlink)", "functions/api/kebab.func (symlink)", "functions/api/meta/test.func (symlink)", @@ -462,7 +474,7 @@ describe("nitro:preset:vercel:web", async () => { "functions/rules/_/noncached/cached-isr.prerender-config.json", "functions/rules/isr-ttl/[...]-isr.func (symlink)", "functions/rules/isr-ttl/[...]-isr.prerender-config.json", - "functions/rules/isr/[...]-isr.func (symlink)", + "functions/rules/isr/[...]-isr.func", "functions/rules/isr/[...]-isr.prerender-config.json", "functions/rules/swr-ttl/[...]-isr.func (symlink)", "functions/rules/swr-ttl/[...]-isr.prerender-config.json", @@ -478,6 +490,47 @@ describe("nitro:preset:vercel:web", async () => { ] `); }); + + it("should create custom function directory for functionRules (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 functionRules 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(100); + expect(config.handler).toBe("index.mjs"); + expect(config.launcherType).toBe("Nodejs"); + expect(config.supportsResponseStreaming).toBe(true); + }); + + it("should write functionRules 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", consumer: "api_Secho" }, + ]); + expect(config.handler).toBe("index.mjs"); + }); + + it("should copy files inside functionRules directory from __server.func", async () => { + const funcDir = resolve(ctx.outDir, "functions/api/hello.func"); + const indexStat = await fsp.lstat(resolve(funcDir, "index.mjs")); + expect(indexStat.isFile()).toBe(true); + }); + + it("should keep base __server.func without functionRules 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"); + }); } ); });