diff --git a/src/presets/vercel/types.ts b/src/presets/vercel/types.ts index 817f0c52e0..b365eb669d 100644 --- a/src/presets/vercel/types.ts +++ b/src/presets/vercel/types.ts @@ -127,6 +127,24 @@ export interface VercelOptions { regions?: string[]; functions?: VercelServerlessFunctionConfig; + + /** + * 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 e382f6fbb8..9c3371b97a 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -60,11 +60,7 @@ export async function generateFunctionFiles(nitro: Nitro) { } } - const functionConfigPath = resolve( - nitro.options.output.serverDir, - ".vc-config.json" - ); - const functionConfig: VercelServerlessFunctionConfig = { + const baseFunctionConfig: VercelServerlessFunctionConfig = { runtime, ...nitro.options.vercel?.functions, handler: "index.mjs", @@ -72,9 +68,48 @@ export async function generateFunctionFiles(nitro: Nitro) { shouldAddHelpers: false, supportsResponseStreaming: true, }; - await writeFile(functionConfigPath, JSON.stringify(functionConfig, null, 2)); + + if ( + Array.isArray(baseFunctionConfig.experimentalTriggers) && + (baseFunctionConfig.experimentalTriggers as unknown[]).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 accessible 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 + ? Object.fromEntries( + Object.entries(nitro.options.vercel.functionRules).map(([k, v]) => [ + withLeadingSlash(k), + v, + ]) + ) + : undefined; + const hasFunctionRules = + functionRules && Object.keys(functionRules).length > 0; + let routeFuncMatcher: ReturnType | undefined; + if (hasFunctionRules) { + routeFuncMatcher = toRouteMatcher( + createRadixRouter({ routes: functionRules }) + ); + } // Write ISR functions + // Tracks base (non-ISR-suffixed) func paths for routes that have ISR, + // so functionRules loop can skip patterns already handled here. + const isrBasePaths = new Set(); for (const [key, value] of Object.entries(nitro.options.routeRules)) { if (!value.isr) { continue; @@ -86,11 +121,36 @@ 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 matchedRules = routeFuncMatcher + ? (defu( + {}, + ...routeFuncMatcher.matchAll(key).reverse() + ) as VercelServerlessFunctionConfig) + : undefined; + if (matchedRules && Object.keys(matchedRules).length > 0) { + isrBasePaths.add( + resolve( + nitro.options.output.serverDir, + "..", + normalizeRouteDest(key) + ".func" + ) + ); + await createFunctionDirWithCustomConfig( + funcPrefix + ".func", + nitro.options.output.serverDir, + baseFunctionConfig, + matchedRules, + 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, @@ -98,6 +158,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 (isrBasePaths.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; @@ -108,7 +192,7 @@ export async function generateFunctionFiles(nitro: Nitro) { const _getRouteRules = (path: string) => defu({}, ..._routeRulesMatcher.matchAll(path).reverse()) as NitroRouteRules; for (const route of o11Routes) { - const routeRules = _getRouteRules(route.src); + const routeRules = _getRouteRules(route.pattern); if (routeRules.isr) { continue; // #3563 } @@ -117,12 +201,35 @@ export async function generateFunctionFiles(nitro: Nitro) { "..", 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 matchedRules = routeFuncMatcher + ? (defu( + {}, + ...routeFuncMatcher.matchAll(route.pattern).reverse() + ) as VercelServerlessFunctionConfig) + : undefined; + if (matchedRules && Object.keys(matchedRules).length > 0) { + await createFunctionDirWithCustomConfig( + funcDir, + nitro.options.output.serverDir, + baseFunctionConfig, + matchedRules, + route.dest + ); + } else { + await fsp.mkdir(dirname(funcPrefix), { recursive: true }); + await fsp.symlink( + "./" + relative(dirname(funcPrefix), nitro.options.output.serverDir), + funcDir, + "junction" + ); + } } } @@ -263,6 +370,22 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { ), }; }), + // Route function config routes (skip patterns already handled by ISR or observability) + ...(nitro.options.vercel?.functionRules + ? Object.keys(nitro.options.vercel.functionRules) + .map((p) => withLeadingSlash(p)) + .filter( + (pattern) => + !rules.some(([key, value]) => value.isr && key === pattern) && + !(o11Routes || []).some( + (r) => r.dest === normalizeRouteDest(pattern) + ) + ) + .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), @@ -335,8 +458,9 @@ function _hasProp(obj: any, prop: string) { // --- utils for observability --- type ObservabilityRoute = { - src: string; // route pattern + src: string; // PCRE-compatible route pattern for config.json dest: string; // function name + pattern: string; // original radix3-compatible route pattern }; function getObservabilityRoutes(nitro: Nitro): ObservabilityRoute[] { @@ -390,6 +514,7 @@ function normalizeRoutes(routes: string[]) { .map((route) => ({ src: normalizeRouteSrc(route), dest: normalizeRouteDest(route), + pattern: route, })); } @@ -454,6 +579,74 @@ 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) { + switch (char) { + case "_": { + result += "__"; + break; + } + case "/": { + result += "_S"; + break; + } + case ".": { + result += "_D"; + break; + } + default: { + result += /[A-Za-z0-9-]/.test(char) + ? char + : "_" + + 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 }); + // defu merges arrays, but for function config we want overrides to replace arrays entirely + const mergedConfig = { + ...defu(overrides, baseFunctionConfig), + ...Object.fromEntries( + Object.entries(overrides).filter(([, v]) => Array.isArray(v)) + ), + }; + + // 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 8e7b56d1fa..e12dd953e1 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -3,6 +3,22 @@ import { defineNitroConfig } from "nitropack/config"; import { dirname, resolve } from "node:path"; export default defineNitroConfig({ + vercel: { + functionRules: { + "/api/hello": { + maxDuration: 100, + }, + "/api/echo": { + experimentalTriggers: [{ type: "queue/v2beta", topic: "orders" }], + }, + "/rules/isr/**": { + regions: ["lhr1", "cdg1"], + }, + "/api/storage/**": { + maxDuration: 60, + }, + }, + }, compressPublicAssets: true, compatibilityDate: "latest", framework: { diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index 4d3a8c5416..ace7559322 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -149,6 +149,14 @@ describe("nitro:preset:vercel", async () => { "dest": "/rules/swr-ttl/[...]-isr?__isr_route=$__isr_route", "src": "(?<__isr_route>/rules/swr-ttl/(?:.*))", }, + { + "dest": "/api/hello", + "src": "/api/hello", + }, + { + "dest": "/api/storage/[...]", + "src": "/api/storage/(?:.*)", + }, { "dest": "/wasm/static-import", "src": "/wasm/static-import", @@ -442,7 +450,10 @@ describe("nitro:preset:vercel", async () => { items.push(`${dirname}/${entry.name}`); } else if (entry.isSymbolicLink()) { items.push(`${dirname}/${entry.name} (symlink)`); - } else if (/chunks|node_modules/.test(entry.name)) { + } else if ( + /chunks|node_modules/.test(entry.name) || + (entry.name.endsWith(".func") && entry.name !== "__fallback.func") + ) { items.push(`${dirname}/${entry.name}`); } else if (entry.isDirectory()) { items.push( @@ -471,10 +482,11 @@ describe("nitro:preset:vercel", async () => { "functions/__fallback.func/timing.js", "functions/api/cached.func (symlink)", "functions/api/db.func (symlink)", - "functions/api/echo.func (symlink)", + "functions/api/echo.func", "functions/api/error.func (symlink)", "functions/api/errors.func (symlink)", "functions/api/headers.func (symlink)", + "functions/api/hello.func", "functions/api/hello2.func (symlink)", "functions/api/import-meta.func (symlink)", "functions/api/kebab.func (symlink)", @@ -492,8 +504,9 @@ describe("nitro:preset:vercel", async () => { "functions/api/serialized/set.func (symlink)", "functions/api/serialized/tuple.func (symlink)", "functions/api/serialized/void.func (symlink)", - "functions/api/storage/dev.func (symlink)", - "functions/api/storage/item.func (symlink)", + "functions/api/storage/[...].func", + "functions/api/storage/dev.func", + "functions/api/storage/item.func", "functions/api/test/[-]/foo.func (symlink)", "functions/api/typed/catchall/[slug]/[...another].func (symlink)", "functions/api/typed/catchall/some/[...test].func (symlink)", @@ -533,7 +546,7 @@ describe("nitro:preset:vercel", 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", @@ -548,6 +561,85 @@ describe("nitro:preset:vercel", 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 __fallback.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 apply functionRules overrides to ISR function directories", async () => { + const config = await fsp + .readFile( + resolve( + ctx.outDir, + "functions/rules/isr/[...]-isr.func/.vc-config.json" + ), + "utf8" + ) + .then((r) => JSON.parse(r)); + expect(config.regions).toEqual(["lhr1", "cdg1"]); + expect(config.handler).toBe("index.mjs"); + expect(config.supportsResponseStreaming).toBe(true); + }); + + it("should keep base __fallback.func without functionRules overrides", async () => { + const config = await fsp + .readFile( + resolve(ctx.outDir, "functions/__fallback.func/.vc-config.json"), + "utf8" + ) + .then((r) => JSON.parse(r)); + expect(config.maxDuration).toBeUndefined(); + expect(config.handler).toBe("index.mjs"); + }); + + it("should apply wildcard functionRules to observability route directories", async () => { + // /api/storage/dev is an o11y route that matches the /api/storage/** functionRule + const funcDir = resolve(ctx.outDir, "functions/api/storage/dev.func"); + const stat = await fsp.lstat(funcDir); + expect(stat.isDirectory()).toBe(true); + expect(stat.isSymbolicLink()).toBe(false); + const config = await fsp + .readFile(resolve(funcDir, ".vc-config.json"), "utf8") + .then((r) => JSON.parse(r)); + expect(config.maxDuration).toBe(60); + expect(config.handler).toBe("index.mjs"); + expect(config.supportsResponseStreaming).toBe(true); + }); } ); });