From da1808e0fc9b239ffbe463e8cc67ff1fdb4b7f76 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Tue, 17 Mar 2026 15:37:04 +0000 Subject: [PATCH 01/24] feat(vercel): allow overriding function config by route --- src/presets/vercel/types.ts | 18 ++++++ src/presets/vercel/utils.ts | 114 +++++++++++++++++++++++++++++++----- test/presets/vercel.test.ts | 72 +++++++++++++++++++++++ 3 files changed, 190 insertions(+), 14 deletions(-) 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..6dfc771939 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: normalizeRouteSrc(pattern), + dest: withLeadingSlash(normalizeRouteDest(pattern)), + })) + : []), // Observability routes ...(o11Routes || []).map((route) => ({ src: joinURL(nitro.options.baseURL, route.src), @@ -512,6 +579,25 @@ 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); + await writeFile(resolve(funcDir, ".vc-config.json"), JSON.stringify(mergedConfig, null, 2)); +} + async function writePrerenderConfig( filename: string, isrConfig: NitroRouteRules["isr"], diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index e61e808760..e5797fcfe0 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -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"); From 24d92dc2eec2f3f28a642401c8f719418f46aff8 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 18 Mar 2026 12:26:00 +0000 Subject: [PATCH 02/24] fix: factor in base url with function route matching --- src/presets/vercel/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index 6dfc771939..e4f71f4c7d 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -336,7 +336,7 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { // Route function config routes ...(nitro.options.vercel?.routeFunctionConfig ? Object.keys(nitro.options.vercel.routeFunctionConfig).map((pattern) => ({ - src: normalizeRouteSrc(pattern), + src: joinURL(nitro.options.baseURL, normalizeRouteSrc(pattern)), dest: withLeadingSlash(normalizeRouteDest(pattern)), })) : []), From ba05eb0f65b61290d8b51a28f4a2e33265608d2b Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 18 Mar 2026 12:30:10 +0000 Subject: [PATCH 03/24] fix: replace arrays rather than merge --- src/presets/vercel/utils.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index e4f71f4c7d..6d443f12f2 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -595,6 +595,11 @@ async function createFunctionDirWithCustomConfig( 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)); } From 2758d9b43fd61a683e50c11e819f8908f337cd42 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 18 Mar 2026 13:17:48 +0000 Subject: [PATCH 04/24] docs(vercel): function config overrides --- docs/2.deploy/20.providers/vercel.md | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index e835edf324..dcb333efee 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. + +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. From af5775ab8a16ec08568c0c3780c642df1ee9a72e Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 18 Mar 2026 13:26:53 +0000 Subject: [PATCH 05/24] Update docs/2.deploy/20.providers/vercel.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- docs/2.deploy/20.providers/vercel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index dcb333efee..586ba05275 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -57,7 +57,7 @@ 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. +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). From 0b8d4c551c6b0c32605d6b5ac0bae3b6a32ed8e0 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 18 Mar 2026 14:10:48 +0000 Subject: [PATCH 06/24] test(vercel): move to main fixture --- test/fixture/nitro.config.ts | 10 +++ test/presets/vercel.test.ts | 161 +++++++++++++++++++---------------- 2 files changed, 97 insertions(+), 74 deletions(-) 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 e5797fcfe0..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"); + }); } ); }); @@ -531,78 +616,6 @@ 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"); From ac313e11bb55840a92a40a86dba0ce7e91d39f62 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 14:21:00 -0700 Subject: [PATCH 07/24] feat(vercel): generate consumer for queue triggers --- src/presets/vercel/utils.ts | 58 +++++++++++++++++++++++++++++++++++-- test/presets/vercel.test.ts | 4 ++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index 6d443f12f2..811fca64ef 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -36,6 +36,29 @@ const ISR_SUFFIX = "-isr"; // Avoid using . as it can conflict with routing const SAFE_FS_CHAR_RE = /[^a-zA-Z0-9_.[\]/]/g; +/** + * 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; +} + function getSystemNodeVersion() { const systemNodeVersion = Number.parseInt(process.versions.node.split(".")[0]); @@ -56,6 +79,18 @@ export async function generateFunctionFiles(nitro: Nitro) { supportsResponseStreaming: true, ...nitro.options.vercel?.functions, }; + + 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.routeFunctionConfig` 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)); @@ -89,7 +124,8 @@ export async function generateFunctionFiles(nitro: Nitro) { funcPrefix + ".func", nitro.options.output.serverDir, baseFunctionConfig, - match.data + match.data, + normalizeRouteDest(key) + ISR_SUFFIX ); } else { await fsp.symlink( @@ -119,7 +155,8 @@ export async function generateFunctionFiles(nitro: Nitro) { funcDir, nitro.options.output.serverDir, baseFunctionConfig, - overrides + overrides, + normalizeRouteDest(pattern) ); createdFuncDirs.add(funcDir); } @@ -150,7 +187,8 @@ export async function generateFunctionFiles(nitro: Nitro) { funcDir, nitro.options.output.serverDir, baseFunctionConfig, - match.data + match.data, + route.dest ); } else { await fsp.mkdir(dirname(funcPrefix), { recursive: true }); @@ -583,6 +621,9 @@ async function createFunctionDirWithCustomConfig( funcDir: string, serverDir: string, baseFunctionConfig: VercelServerlessFunctionConfig, + overrides: VercelServerlessFunctionConfig, + functionPath: string +) { overrides: VercelServerlessFunctionConfig ) { await fsp.mkdir(funcDir, { recursive: true }); @@ -600,6 +641,17 @@ async function createFunctionDirWithCustomConfig( (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)); } diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index 95ac42178d..147f13b6aa 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -546,7 +546,9 @@ describe("nitro:preset:vercel:web", 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.experimentalTriggers).toEqual([ + { type: "queue/v2beta", topic: "orders", consumer: "api_Secho" }, + ]); expect(config.handler).toBe("index.mjs"); }); From 1dd21d5f1a6d22034bd66912fdb50fd3ea4f8ef2 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 14:22:05 -0700 Subject: [PATCH 08/24] fix(vercel): copy function output instead of symlinking when config overridden --- src/presets/vercel/utils.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index 811fca64ef..f4cf8ef039 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -624,17 +624,11 @@ async function createFunctionDirWithCustomConfig( overrides: VercelServerlessFunctionConfig, functionPath: string ) { - 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"); - } + // 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)) { From fba759cd6697539e9a2e479ea32f15a17dbfc9d8 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 14:22:22 -0700 Subject: [PATCH 09/24] test: update fixture --- test/fixture/nitro.config.ts | 5 +++- test/presets/vercel.test.ts | 47 ++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/test/fixture/nitro.config.ts b/test/fixture/nitro.config.ts index a315a842b4..ba3bd84d67 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -7,11 +7,14 @@ export default defineConfig({ vercel: { routeFunctionConfig: { "/api/hello": { - maxDuration: 300, + maxDuration: 100, }, "/api/echo": { experimentalTriggers: [{ type: "queue/v2beta", topic: "orders" }], }, + "/rules/isr/**": { + regions: ["lhr1", "cdg1"], + }, }, }, compressPublicAssets: true, diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index 147f13b6aa..b4fc2efa34 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -175,6 +175,10 @@ describe("nitro:preset:vercel:web", async () => { "dest": "/api/echo", "src": "/api/echo", }, + { + "dest": "/rules/isr/[...]", + "src": "/rules/isr/(?:.*)", + }, { "dest": "/wasm/static-import", "src": "/wasm/static-import", @@ -508,8 +512,47 @@ 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/.vc-config.json", + "functions/rules/isr/[...]-isr.func/_...name_.mjs (symlink)", + "functions/rules/isr/[...]-isr.func/_...name_.mjs.map (symlink)", + "functions/rules/isr/[...]-isr.func/_...param_.mjs (symlink)", + "functions/rules/isr/[...]-isr.func/_...param_.mjs.map (symlink)", + "functions/rules/isr/[...]-isr.func/_...slug_.mjs (symlink)", + "functions/rules/isr/[...]-isr.func/_...slug_.mjs.map (symlink)", + "functions/rules/isr/[...]-isr.func/_chunks (symlink)", + "functions/rules/isr/[...]-isr.func/_id_.mjs (symlink)", + "functions/rules/isr/[...]-isr.func/_id_.mjs.map (symlink)", + "functions/rules/isr/[...]-isr.func/_libs (symlink)", + "functions/rules/isr/[...]-isr.func/_routes (symlink)", + "functions/rules/isr/[...]-isr.func/_tasks (symlink)", + "functions/rules/isr/[...]-isr.func/_test-id_.mjs (symlink)", + "functions/rules/isr/[...]-isr.func/_test-id_.mjs.map (symlink)", + "functions/rules/isr/[...]-isr.func/_virtual (symlink)", + "functions/rules/isr/[...]-isr.func/index.mjs (symlink)", + "functions/rules/isr/[...]-isr.func/index.mjs.map (symlink)", + "functions/rules/isr/[...]-isr.func/node_modules (symlink)", + "functions/rules/isr/[...]-isr.func/package.json (symlink)", "functions/rules/isr/[...]-isr.prerender-config.json", + "functions/rules/isr/[...].func/.vc-config.json", + "functions/rules/isr/[...].func/_...name_.mjs (symlink)", + "functions/rules/isr/[...].func/_...name_.mjs.map (symlink)", + "functions/rules/isr/[...].func/_...param_.mjs (symlink)", + "functions/rules/isr/[...].func/_...param_.mjs.map (symlink)", + "functions/rules/isr/[...].func/_...slug_.mjs (symlink)", + "functions/rules/isr/[...].func/_...slug_.mjs.map (symlink)", + "functions/rules/isr/[...].func/_chunks (symlink)", + "functions/rules/isr/[...].func/_id_.mjs (symlink)", + "functions/rules/isr/[...].func/_id_.mjs.map (symlink)", + "functions/rules/isr/[...].func/_libs (symlink)", + "functions/rules/isr/[...].func/_routes (symlink)", + "functions/rules/isr/[...].func/_tasks (symlink)", + "functions/rules/isr/[...].func/_test-id_.mjs (symlink)", + "functions/rules/isr/[...].func/_test-id_.mjs.map (symlink)", + "functions/rules/isr/[...].func/_virtual (symlink)", + "functions/rules/isr/[...].func/index.mjs (symlink)", + "functions/rules/isr/[...].func/index.mjs.map (symlink)", + "functions/rules/isr/[...].func/node_modules (symlink)", + "functions/rules/isr/[...].func/package.json (symlink)", "functions/rules/swr-ttl/[...]-isr.func (symlink)", "functions/rules/swr-ttl/[...]-isr.prerender-config.json", "functions/rules/swr/[...]-isr.func (symlink)", @@ -536,7 +579,7 @@ describe("nitro:preset:vercel:web", 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.maxDuration).toBe(100); expect(config.handler).toBe("index.mjs"); expect(config.launcherType).toBe("Nodejs"); expect(config.supportsResponseStreaming).toBe(true); From 90d8bb66f9e2493a4088dfe64b1ad5f21c36ddfa Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 14:23:05 -0700 Subject: [PATCH 10/24] test: copy function output instead of symlinking when config overridden --- test/presets/vercel.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index b4fc2efa34..d6e145e39b 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -595,10 +595,10 @@ describe("nitro:preset:vercel:web", async () => { expect(config.handler).toBe("index.mjs"); }); - it("should symlink files inside routeFunctionConfig directory to __server.func", async () => { + it("should copy files inside routeFunctionConfig 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.isSymbolicLink()).toBe(true); + expect(indexStat.isFile()).toBe(true); }); it("should keep base __server.func without routeFunctionConfig overrides", async () => { From beb380e9efb0efa5155049cdc03a1663cdd0aedf Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 14:45:58 -0700 Subject: [PATCH 11/24] test: copy function output instead of symlinking when config overridden --- test/presets/vercel.test.ts | 152 ++++++++++++++++++------------------ 1 file changed, 76 insertions(+), 76 deletions(-) diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index d6e145e39b..f1253483ed 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -433,46 +433,46 @@ describe("nitro:preset:vercel:web", async () => { "functions/api/cached.func (symlink)", "functions/api/db.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/echo.func/_...name_.mjs", + "functions/api/echo.func/_...name_.mjs.map", + "functions/api/echo.func/_...param_.mjs", + "functions/api/echo.func/_...param_.mjs.map", + "functions/api/echo.func/_...slug_.mjs", + "functions/api/echo.func/_...slug_.mjs.map", + "functions/api/echo.func/_chunks", + "functions/api/echo.func/_id_.mjs", + "functions/api/echo.func/_id_.mjs.map", + "functions/api/echo.func/_libs", + "functions/api/echo.func/_routes", + "functions/api/echo.func/_tasks", + "functions/api/echo.func/_test-id_.mjs", + "functions/api/echo.func/_test-id_.mjs.map", + "functions/api/echo.func/_virtual", + "functions/api/echo.func/index.mjs", + "functions/api/echo.func/index.mjs.map", + "functions/api/echo.func/node_modules", + "functions/api/echo.func/package.json", "functions/api/headers.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/hello.func/_...name_.mjs", + "functions/api/hello.func/_...name_.mjs.map", + "functions/api/hello.func/_...param_.mjs", + "functions/api/hello.func/_...param_.mjs.map", + "functions/api/hello.func/_...slug_.mjs", + "functions/api/hello.func/_...slug_.mjs.map", + "functions/api/hello.func/_chunks", + "functions/api/hello.func/_id_.mjs", + "functions/api/hello.func/_id_.mjs.map", + "functions/api/hello.func/_libs", + "functions/api/hello.func/_routes", + "functions/api/hello.func/_tasks", + "functions/api/hello.func/_test-id_.mjs", + "functions/api/hello.func/_test-id_.mjs.map", + "functions/api/hello.func/_virtual", + "functions/api/hello.func/index.mjs", + "functions/api/hello.func/index.mjs.map", + "functions/api/hello.func/node_modules", + "functions/api/hello.func/package.json", "functions/api/hey.func (symlink)", "functions/api/kebab.func (symlink)", "functions/api/meta/test.func (symlink)", @@ -513,46 +513,46 @@ describe("nitro:preset:vercel:web", async () => { "functions/rules/isr-ttl/[...]-isr.func (symlink)", "functions/rules/isr-ttl/[...]-isr.prerender-config.json", "functions/rules/isr/[...]-isr.func/.vc-config.json", - "functions/rules/isr/[...]-isr.func/_...name_.mjs (symlink)", - "functions/rules/isr/[...]-isr.func/_...name_.mjs.map (symlink)", - "functions/rules/isr/[...]-isr.func/_...param_.mjs (symlink)", - "functions/rules/isr/[...]-isr.func/_...param_.mjs.map (symlink)", - "functions/rules/isr/[...]-isr.func/_...slug_.mjs (symlink)", - "functions/rules/isr/[...]-isr.func/_...slug_.mjs.map (symlink)", - "functions/rules/isr/[...]-isr.func/_chunks (symlink)", - "functions/rules/isr/[...]-isr.func/_id_.mjs (symlink)", - "functions/rules/isr/[...]-isr.func/_id_.mjs.map (symlink)", - "functions/rules/isr/[...]-isr.func/_libs (symlink)", - "functions/rules/isr/[...]-isr.func/_routes (symlink)", - "functions/rules/isr/[...]-isr.func/_tasks (symlink)", - "functions/rules/isr/[...]-isr.func/_test-id_.mjs (symlink)", - "functions/rules/isr/[...]-isr.func/_test-id_.mjs.map (symlink)", - "functions/rules/isr/[...]-isr.func/_virtual (symlink)", - "functions/rules/isr/[...]-isr.func/index.mjs (symlink)", - "functions/rules/isr/[...]-isr.func/index.mjs.map (symlink)", - "functions/rules/isr/[...]-isr.func/node_modules (symlink)", - "functions/rules/isr/[...]-isr.func/package.json (symlink)", + "functions/rules/isr/[...]-isr.func/_...name_.mjs", + "functions/rules/isr/[...]-isr.func/_...name_.mjs.map", + "functions/rules/isr/[...]-isr.func/_...param_.mjs", + "functions/rules/isr/[...]-isr.func/_...param_.mjs.map", + "functions/rules/isr/[...]-isr.func/_...slug_.mjs", + "functions/rules/isr/[...]-isr.func/_...slug_.mjs.map", + "functions/rules/isr/[...]-isr.func/_chunks", + "functions/rules/isr/[...]-isr.func/_id_.mjs", + "functions/rules/isr/[...]-isr.func/_id_.mjs.map", + "functions/rules/isr/[...]-isr.func/_libs", + "functions/rules/isr/[...]-isr.func/_routes", + "functions/rules/isr/[...]-isr.func/_tasks", + "functions/rules/isr/[...]-isr.func/_test-id_.mjs", + "functions/rules/isr/[...]-isr.func/_test-id_.mjs.map", + "functions/rules/isr/[...]-isr.func/_virtual", + "functions/rules/isr/[...]-isr.func/index.mjs", + "functions/rules/isr/[...]-isr.func/index.mjs.map", + "functions/rules/isr/[...]-isr.func/node_modules", + "functions/rules/isr/[...]-isr.func/package.json", "functions/rules/isr/[...]-isr.prerender-config.json", "functions/rules/isr/[...].func/.vc-config.json", - "functions/rules/isr/[...].func/_...name_.mjs (symlink)", - "functions/rules/isr/[...].func/_...name_.mjs.map (symlink)", - "functions/rules/isr/[...].func/_...param_.mjs (symlink)", - "functions/rules/isr/[...].func/_...param_.mjs.map (symlink)", - "functions/rules/isr/[...].func/_...slug_.mjs (symlink)", - "functions/rules/isr/[...].func/_...slug_.mjs.map (symlink)", - "functions/rules/isr/[...].func/_chunks (symlink)", - "functions/rules/isr/[...].func/_id_.mjs (symlink)", - "functions/rules/isr/[...].func/_id_.mjs.map (symlink)", - "functions/rules/isr/[...].func/_libs (symlink)", - "functions/rules/isr/[...].func/_routes (symlink)", - "functions/rules/isr/[...].func/_tasks (symlink)", - "functions/rules/isr/[...].func/_test-id_.mjs (symlink)", - "functions/rules/isr/[...].func/_test-id_.mjs.map (symlink)", - "functions/rules/isr/[...].func/_virtual (symlink)", - "functions/rules/isr/[...].func/index.mjs (symlink)", - "functions/rules/isr/[...].func/index.mjs.map (symlink)", - "functions/rules/isr/[...].func/node_modules (symlink)", - "functions/rules/isr/[...].func/package.json (symlink)", + "functions/rules/isr/[...].func/_...name_.mjs", + "functions/rules/isr/[...].func/_...name_.mjs.map", + "functions/rules/isr/[...].func/_...param_.mjs", + "functions/rules/isr/[...].func/_...param_.mjs.map", + "functions/rules/isr/[...].func/_...slug_.mjs", + "functions/rules/isr/[...].func/_...slug_.mjs.map", + "functions/rules/isr/[...].func/_chunks", + "functions/rules/isr/[...].func/_id_.mjs", + "functions/rules/isr/[...].func/_id_.mjs.map", + "functions/rules/isr/[...].func/_libs", + "functions/rules/isr/[...].func/_routes", + "functions/rules/isr/[...].func/_tasks", + "functions/rules/isr/[...].func/_test-id_.mjs", + "functions/rules/isr/[...].func/_test-id_.mjs.map", + "functions/rules/isr/[...].func/_virtual", + "functions/rules/isr/[...].func/index.mjs", + "functions/rules/isr/[...].func/index.mjs.map", + "functions/rules/isr/[...].func/node_modules", + "functions/rules/isr/[...].func/package.json", "functions/rules/swr-ttl/[...]-isr.func (symlink)", "functions/rules/swr-ttl/[...]-isr.prerender-config.json", "functions/rules/swr/[...]-isr.func (symlink)", From bbe5bf95102305801d47eddf85375fc655ea53cd Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 18 Mar 2026 13:37:39 +0000 Subject: [PATCH 12/24] feat(vercel): queues --- docs/2.deploy/20.providers/vercel.md | 61 +++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 24 +++++++- src/presets/vercel/preset.ts | 30 ++++++++++ src/presets/vercel/runtime/queue-handler.ts | 11 ++++ src/presets/vercel/types.ts | 47 ++++++++++++++++ test/presets/vercel.test.ts | 40 ++++++++++++++ 7 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 src/presets/vercel/runtime/queue-handler.ts diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index 586ba05275..48af8fd975 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -148,6 +148,67 @@ export default defineNitroConfig({ To prevent unauthorized access to the cron handler, set a `CRON_SECRET` environment variable in your Vercel project settings. When `CRON_SECRET` is set, Nitro validates the `Authorization` header on every cron invocation. +## Queues + +:read-more{title="Vercel Queues" to="https://vercel.com/docs/queues"} + +Nitro integrates with [Vercel Queues](https://vercel.com/docs/queues) to process messages asynchronously. Define your queue topics in the Nitro config and handle incoming messages with the `vercel:queue` runtime hook. + +```ts [nitro.config.ts] +export default defineNitroConfig({ + vercel: { + queues: { + triggers: [ + { topic: "orders" }, + { topic: "notifications" }, + ], + }, + }, +}); +``` + +### Handling messages + +Use the `vercel:queue` hook in a [Nitro plugin](/guide/plugins) to process incoming queue messages: + +```ts [server/plugins/queues.ts] +export default defineNitroPlugin((nitro) => { + nitro.hooks.hook("vercel:queue", ({ message, metadata }) => { + console.log(`[${metadata.topicName}] Message ${metadata.messageId}:`, message); + }); +}); +``` + +### Running tasks from queue messages + +You can use queue messages to trigger [Nitro tasks](/tasks): + +```ts [server/plugins/queues.ts] +import { runTask } from "nitro/task"; + +export default defineNitroPlugin((nitro) => { + nitro.hooks.hook("vercel:queue", async ({ message, metadata }) => { + if (metadata.topicName === "orders") { + await runTask("orders:fulfill", { payload: message }); + } + }); +}); +``` + +### Sending messages + +Use the `@vercel/queue` package directly to send messages to a topic: + +```ts [server/routes/api/orders.post.ts] +import { send } from "@vercel/queue"; + +export default defineEventHandler(async (event) => { + const order = await event.req.json(); + const { messageId } = await send("orders", order); + return { messageId }; +}); +``` + ## Custom build output configuration You can provide additional [build output configuration](https://vercel.com/docs/build-output-api/v3) using `vercel.config` key inside `nitro.config`. It will be merged with built-in auto-generated config. diff --git a/package.json b/package.json index fbebc948b0..ee0ef989ec 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "@types/semver": "^7.7.1", "@types/xml2js": "^0.4.14", "@typescript/native-preview": "latest", + "@vercel/queue": "^0.1.4", "@vitest/coverage-v8": "^4.1.1", "automd": "^0.4.3", "c12": "^4.0.0-beta.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f5e61f29c..dccd3e2fa1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,9 @@ importers: '@typescript/native-preview': specifier: latest version: 7.0.0-dev.20260323.1 + '@vercel/queue': + specifier: ^0.1.4 + version: 0.1.4 '@vitest/coverage-v8': specifier: ^4.1.1 version: 4.1.1(vitest@4.1.1(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) @@ -3272,6 +3275,10 @@ packages: resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} + '@vercel/queue@0.1.4': + resolution: {integrity: sha512-wo+jCycmCX078vQSbkX+RcLvySONDCK0f9aQp5UMKQD1+B+xKt3YVbIYbZukvoHQpbm5nnk6If+ADSeK/PmCgQ==} + engines: {node: '>=20.0.0'} + '@vitejs/plugin-react@6.0.1': resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5432,6 +5439,10 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mixpart@0.0.5: + resolution: {integrity: sha512-TpWi9/2UIr7VWCVAM7NB4WR4yOglAetBkuKfxs3K0vFcUukqAaW1xsgX0v1gNGiDKzYhPHFcHgarC7jmnaOy4w==} + engines: {node: '>=20.0.0'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -9705,6 +9716,13 @@ snapshots: '@vercel/oidc@3.1.0': {} + '@vercel/queue@0.1.4': + dependencies: + '@vercel/oidc': 3.1.0 + minimatch: 10.2.4 + mixpart: 0.0.5 + picocolors: 1.1.1 + '@vitejs/plugin-react@6.0.1(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 @@ -10045,7 +10063,7 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 - axios-retry@4.5.0(axios@1.13.6): + axios-retry@4.5.0(axios@1.13.6(debug@4.4.3)): dependencies: axios: 1.13.6(debug@4.4.3) is-retry-allowed: 2.2.0 @@ -12114,6 +12132,8 @@ snapshots: minipass@7.1.3: {} + mixpart@0.0.5: {} + mkdirp-classic@0.5.3: {} mkdirp@0.5.6: @@ -13996,7 +14016,7 @@ snapshots: dependencies: '@toon-format/toon': 0.9.0 axios: 1.13.6(debug@4.4.3) - axios-retry: 4.5.0(axios@1.13.6) + axios-retry: 4.5.0(axios@1.13.6(debug@4.4.3)) debug: 4.4.3 eventsource: 4.1.0 git-url-parse: 15.0.0 diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index a24420f44b..b148d8b0f5 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -2,6 +2,7 @@ import { defineNitroPreset } from "../_utils/preset.ts"; import type { Nitro } from "nitro/types"; import { presetsDir } from "nitro/meta"; import { join } from "pathe"; +import { importDep } from "../../utils/dep.ts"; import { deprecateSWR, generateFunctionFiles, @@ -65,6 +66,35 @@ const vercel = defineNitroPreset( handler: join(presetsDir, "vercel/runtime/cron-handler"), }); } + + // Queue consumer handler + const queues = nitro.options.vercel?.queues; + if (queues?.triggers?.length) { + await importDep({ + id: "@vercel/queue", + dir: nitro.options.rootDir, + reason: "Vercel Queues", + }); + + const handlerRoute = queues.handlerRoute || "/_vercel/queues/consumer"; + + nitro.options.handlers.push({ + route: handlerRoute, + lazy: true, + handler: join(presetsDir, "vercel/runtime/queue-handler"), + }); + + nitro.options.vercel!.routeFunctionConfig = { + ...nitro.options.vercel!.routeFunctionConfig, + [handlerRoute]: { + ...nitro.options.vercel!.routeFunctionConfig?.[handlerRoute], + experimentalTriggers: queues.triggers.map((t) => ({ + type: "queue/v2beta" as const, + topic: t.topic, + })), + }, + }; + } }, "rollup:before": (nitro: Nitro) => { deprecateSWR(nitro); diff --git a/src/presets/vercel/runtime/queue-handler.ts b/src/presets/vercel/runtime/queue-handler.ts new file mode 100644 index 0000000000..505aa79fb6 --- /dev/null +++ b/src/presets/vercel/runtime/queue-handler.ts @@ -0,0 +1,11 @@ +import { handleCallback } from "@vercel/queue"; +import { defineHandler } from "nitro"; +import { useNitroHooks } from "nitro/app"; + +const handler = handleCallback(async (message, metadata) => { + await useNitroHooks().callHook("vercel:queue", { message, metadata }); +}); + +export default defineHandler((event) => { + return handler(event.req as Request); +}); diff --git a/src/presets/vercel/types.ts b/src/presets/vercel/types.ts index 7f92d6fd8b..6f9e9537a8 100644 --- a/src/presets/vercel/types.ts +++ b/src/presets/vercel/types.ts @@ -148,6 +148,44 @@ export interface VercelOptions { */ cronHandlerRoute?: string; + /** + * Vercel Queues configuration. + * + * Messages are delivered via the `vercel:queue` runtime hook. + * + * @example + * ```ts + * // nitro.config.ts + * export default defineNitroConfig({ + * vercel: { + * queues: { + * triggers: [{ topic: "orders" }], + * }, + * }, + * }); + * ``` + * + * ```ts + * // server/plugins/queues.ts + * export default defineNitroPlugin((nitro) => { + * nitro.hooks.hook("vercel:queue", ({ message, metadata }) => { + * console.log(`Received message on ${metadata.topicName}:`, message); + * }); + * }); + * ``` + * + * @see https://vercel.com/docs/queues + */ + queues?: { + /** + * Route path for the queue consumer handler. + * @default "/_vercel/queues/consumer" + */ + handlerRoute?: string; + /** Queue topic triggers to subscribe to. */ + triggers: Array<{ topic: string }>; + }; + /** * Per-route function configuration overrides. * @@ -206,3 +244,12 @@ export type PrerenderFunctionConfig = { */ exposeErrBody?: boolean; }; + +declare module "nitro/types" { + export interface NitroRuntimeHooks { + "vercel:queue": (_: { + message: unknown; + metadata: import("@vercel/queue").MessageMetadata; + }) => void; + } +} diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index f1253483ed..680bf4412f 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -661,6 +661,46 @@ describe("nitro:preset:vercel:bun", async () => { }); }); +describe("nitro:preset:vercel:queues", async () => { + const ctx = await setupTest("vercel", { + outDirSuffix: "-queues", + config: { + preset: "vercel", + vercel: { + queues: { + triggers: [{ topic: "orders" }, { topic: "notifications" }], + }, + }, + }, + }); + + it("should create queue consumer function directory with experimentalTriggers", async () => { + const funcDir = resolve(ctx.outDir, "functions/_vercel/queues/consumer.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.experimentalTriggers).toEqual([ + { type: "queue/v2beta", topic: "orders" }, + { type: "queue/v2beta", topic: "notifications" }, + ]); + expect(config.handler).toBe("index.mjs"); + }); + + it("should add queue consumer route 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 queueRoute = routes.find( + (r) => r.dest === "/_vercel/queues/consumer" && r.src === "/_vercel/queues/consumer" + ); + expect(queueRoute).toBeDefined(); + }); +}); describe.skip("nitro:preset:vercel:bun-verceljson", async () => { const vercelJsonPath = join(fixtureDir, "vercel.json"); From 54892f1b056f24e7edf791067d3f487ae7bab19f Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 15:03:52 -0700 Subject: [PATCH 13/24] refactor: use nitro routing --- src/presets/vercel/utils.ts | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index f4cf8ef039..c2568818a6 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -3,7 +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 { Router } from "../../routing.ts"; import { joinURL, withLeadingSlash, withoutLeadingSlash } from "ufo"; import type { PrerenderFunctionConfig, @@ -94,15 +94,18 @@ export async function generateFunctionFiles(nitro: Nitro) { 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; + let routeFuncRouter: Router | undefined; if (hasRouteFunctionConfig) { - routeFuncRouter = createRouter(); - for (const [pattern, overrides] of Object.entries(routeFunctionConfig)) { - addRoute(routeFuncRouter, "", pattern, overrides); - } + routeFuncRouter = new Router(); + routeFuncRouter._update( + Object.entries(routeFunctionConfig).map(([route, data]) => ({ + route, + method: "", + data, + })) + ); } // Write ISR functions @@ -118,13 +121,13 @@ export async function generateFunctionFiles(nitro: Nitro) { ); await fsp.mkdir(dirname(funcPrefix), { recursive: true }); - const match = routeFuncRouter && findRoute(routeFuncRouter, "", key); - if (match) { + const matchData = routeFuncRouter?.match("", key); + if (matchData) { await createFunctionDirWithCustomConfig( funcPrefix + ".func", nitro.options.output.serverDir, baseFunctionConfig, - match.data, + matchData, normalizeRouteDest(key) + ISR_SUFFIX ); } else { @@ -181,13 +184,13 @@ export async function generateFunctionFiles(nitro: Nitro) { continue; } - const match = routeFuncRouter && findRoute(routeFuncRouter, "", route.src); - if (match) { + const matchData = routeFuncRouter?.match("", route.src); + if (matchData) { await createFunctionDirWithCustomConfig( funcDir, nitro.options.output.serverDir, baseFunctionConfig, - match.data, + matchData, route.dest ); } else { From 8c23741fb9dffb38e1f737130fde71c44febac11 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 15:06:38 -0700 Subject: [PATCH 14/24] style: move new utils to end --- src/presets/vercel/utils.ts | 46 ++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index c2568818a6..e7bd8991fc 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -36,29 +36,6 @@ const ISR_SUFFIX = "-isr"; // Avoid using . as it can conflict with routing const SAFE_FS_CHAR_RE = /[^a-zA-Z0-9_.[\]/]/g; -/** - * 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; -} - function getSystemNodeVersion() { const systemNodeVersion = Number.parseInt(process.versions.node.split(".")[0]); @@ -620,6 +597,29 @@ 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, From 5355a5be346d28024c3fbe864da3925d1bdb26ed Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 15:53:13 -0700 Subject: [PATCH 15/24] test: tidy snapshot --- test/presets/vercel.test.ts | 86 +++---------------------------------- 1 file changed, 5 insertions(+), 81 deletions(-) diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index f1253483ed..b613f89c11 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -409,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}`)); @@ -432,47 +432,9 @@ describe("nitro:preset:vercel:web", async () => { "functions/_vercel", "functions/api/cached.func (symlink)", "functions/api/db.func (symlink)", - "functions/api/echo.func/.vc-config.json", - "functions/api/echo.func/_...name_.mjs", - "functions/api/echo.func/_...name_.mjs.map", - "functions/api/echo.func/_...param_.mjs", - "functions/api/echo.func/_...param_.mjs.map", - "functions/api/echo.func/_...slug_.mjs", - "functions/api/echo.func/_...slug_.mjs.map", - "functions/api/echo.func/_chunks", - "functions/api/echo.func/_id_.mjs", - "functions/api/echo.func/_id_.mjs.map", - "functions/api/echo.func/_libs", - "functions/api/echo.func/_routes", - "functions/api/echo.func/_tasks", - "functions/api/echo.func/_test-id_.mjs", - "functions/api/echo.func/_test-id_.mjs.map", - "functions/api/echo.func/_virtual", - "functions/api/echo.func/index.mjs", - "functions/api/echo.func/index.mjs.map", - "functions/api/echo.func/node_modules", - "functions/api/echo.func/package.json", + "functions/api/echo.func", "functions/api/headers.func (symlink)", - "functions/api/hello.func/.vc-config.json", - "functions/api/hello.func/_...name_.mjs", - "functions/api/hello.func/_...name_.mjs.map", - "functions/api/hello.func/_...param_.mjs", - "functions/api/hello.func/_...param_.mjs.map", - "functions/api/hello.func/_...slug_.mjs", - "functions/api/hello.func/_...slug_.mjs.map", - "functions/api/hello.func/_chunks", - "functions/api/hello.func/_id_.mjs", - "functions/api/hello.func/_id_.mjs.map", - "functions/api/hello.func/_libs", - "functions/api/hello.func/_routes", - "functions/api/hello.func/_tasks", - "functions/api/hello.func/_test-id_.mjs", - "functions/api/hello.func/_test-id_.mjs.map", - "functions/api/hello.func/_virtual", - "functions/api/hello.func/index.mjs", - "functions/api/hello.func/index.mjs.map", - "functions/api/hello.func/node_modules", - "functions/api/hello.func/package.json", + "functions/api/hello.func", "functions/api/hey.func (symlink)", "functions/api/kebab.func (symlink)", "functions/api/meta/test.func (symlink)", @@ -512,47 +474,9 @@ 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/.vc-config.json", - "functions/rules/isr/[...]-isr.func/_...name_.mjs", - "functions/rules/isr/[...]-isr.func/_...name_.mjs.map", - "functions/rules/isr/[...]-isr.func/_...param_.mjs", - "functions/rules/isr/[...]-isr.func/_...param_.mjs.map", - "functions/rules/isr/[...]-isr.func/_...slug_.mjs", - "functions/rules/isr/[...]-isr.func/_...slug_.mjs.map", - "functions/rules/isr/[...]-isr.func/_chunks", - "functions/rules/isr/[...]-isr.func/_id_.mjs", - "functions/rules/isr/[...]-isr.func/_id_.mjs.map", - "functions/rules/isr/[...]-isr.func/_libs", - "functions/rules/isr/[...]-isr.func/_routes", - "functions/rules/isr/[...]-isr.func/_tasks", - "functions/rules/isr/[...]-isr.func/_test-id_.mjs", - "functions/rules/isr/[...]-isr.func/_test-id_.mjs.map", - "functions/rules/isr/[...]-isr.func/_virtual", - "functions/rules/isr/[...]-isr.func/index.mjs", - "functions/rules/isr/[...]-isr.func/index.mjs.map", - "functions/rules/isr/[...]-isr.func/node_modules", - "functions/rules/isr/[...]-isr.func/package.json", + "functions/rules/isr/[...]-isr.func", "functions/rules/isr/[...]-isr.prerender-config.json", - "functions/rules/isr/[...].func/.vc-config.json", - "functions/rules/isr/[...].func/_...name_.mjs", - "functions/rules/isr/[...].func/_...name_.mjs.map", - "functions/rules/isr/[...].func/_...param_.mjs", - "functions/rules/isr/[...].func/_...param_.mjs.map", - "functions/rules/isr/[...].func/_...slug_.mjs", - "functions/rules/isr/[...].func/_...slug_.mjs.map", - "functions/rules/isr/[...].func/_chunks", - "functions/rules/isr/[...].func/_id_.mjs", - "functions/rules/isr/[...].func/_id_.mjs.map", - "functions/rules/isr/[...].func/_libs", - "functions/rules/isr/[...].func/_routes", - "functions/rules/isr/[...].func/_tasks", - "functions/rules/isr/[...].func/_test-id_.mjs", - "functions/rules/isr/[...].func/_test-id_.mjs.map", - "functions/rules/isr/[...].func/_virtual", - "functions/rules/isr/[...].func/index.mjs", - "functions/rules/isr/[...].func/index.mjs.map", - "functions/rules/isr/[...].func/node_modules", - "functions/rules/isr/[...].func/package.json", + "functions/rules/isr/[...].func", "functions/rules/swr-ttl/[...]-isr.func (symlink)", "functions/rules/swr-ttl/[...]-isr.prerender-config.json", "functions/rules/swr/[...]-isr.func (symlink)", From 74e10ff0e80371025d68c29ccc4da8b89e8d1441 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 16:36:13 -0700 Subject: [PATCH 16/24] chore: add vercel queues example --- docs/4.examples/vercel-queues.md | 19 +++++ examples/vercel-queues/README.md | 72 +++++++++++++++++++ examples/vercel-queues/nitro.config.ts | 13 ++++ examples/vercel-queues/package.json | 13 ++++ examples/vercel-queues/plugins/queue.ts | 12 ++++ examples/vercel-queues/routes/send.ts | 16 +++++ .../vercel-queues/tasks/notifications/send.ts | 11 +++ examples/vercel-queues/tsconfig.json | 3 + examples/vercel-queues/vite.config.ts | 4 ++ pnpm-lock.yaml | 10 +++ 10 files changed, 173 insertions(+) create mode 100644 docs/4.examples/vercel-queues.md create mode 100644 examples/vercel-queues/README.md create mode 100644 examples/vercel-queues/nitro.config.ts create mode 100644 examples/vercel-queues/package.json create mode 100644 examples/vercel-queues/plugins/queue.ts create mode 100644 examples/vercel-queues/routes/send.ts create mode 100644 examples/vercel-queues/tasks/notifications/send.ts create mode 100644 examples/vercel-queues/tsconfig.json create mode 100644 examples/vercel-queues/vite.config.ts diff --git a/docs/4.examples/vercel-queues.md b/docs/4.examples/vercel-queues.md new file mode 100644 index 0000000000..d9b4357b7a --- /dev/null +++ b/docs/4.examples/vercel-queues.md @@ -0,0 +1,19 @@ +--- +category: deploy +icon: i-simple-icons-vercel +--- + +# Vercel Queues + +> Process background work asynchronously with Vercel Queues and Nitro tasks. + + + + + + + +## Learn More + +- [Vercel Deployment](/deploy/providers/vercel) +- [Tasks](/docs/tasks) diff --git a/examples/vercel-queues/README.md b/examples/vercel-queues/README.md new file mode 100644 index 0000000000..2d8e7a7ae8 --- /dev/null +++ b/examples/vercel-queues/README.md @@ -0,0 +1,72 @@ +Nitro integrates with [Vercel Queues](https://vercel.com/docs/queues) to process background work asynchronously. + +## Configuration + +Add queue triggers to your Nitro config. Nitro registers a queue consumer handler and makes incoming messages available via the `vercel:queue` runtime hook. + +```ts [nitro.config.ts] +import { defineConfig } from "nitro"; + +export default defineConfig({ + experimental: { + tasks: true, + }, + vercel: { + queues: { + triggers: [{ topic: "notifications" }], + }, + }, +}); +``` + +## Sending Messages + +Use the `@vercel/queue` SDK to send messages to a topic from any route. + +```ts [routes/send.ts] +import { send } from "@vercel/queue"; +import { defineHandler } from "nitro"; + +export default defineHandler(async (event) => { + const body = await event.req.json(); + const { messageId } = await send("notifications", { + to: body.to, + subject: body.subject, + body: body.body, + }); + return { messageId }; +}); +``` + +## Processing Messages + +Listen for the `vercel:queue` hook in a plugin to handle incoming messages. This example dispatches them to a Nitro task. + +```ts [plugins/queue.ts] +import { runTask } from "nitro/task"; +import { definePlugin } from "nitro"; + +export default definePlugin((nitro) => { + nitro.hooks.hook("vercel:queue", async ({ message, metadata }) => { + if (metadata.topicName === "notifications") { + await runTask("notifications:send", { + payload: message as Record, + }); + } + }); +}); +``` + +```ts [tasks/notifications/send.ts] +import { defineTask } from "nitro/task"; + +export default defineTask({ + meta: { + description: "Send a notification", + }, + async run({ payload }) { + console.log(`Sending notification to ${payload.to}: ${payload.subject}`); + return { result: "Notification sent" }; + }, +}); +``` diff --git a/examples/vercel-queues/nitro.config.ts b/examples/vercel-queues/nitro.config.ts new file mode 100644 index 0000000000..11c1361646 --- /dev/null +++ b/examples/vercel-queues/nitro.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "nitro"; + +export default defineConfig({ + serverDir: ".", + experimental: { + tasks: true, + }, + vercel: { + queues: { + triggers: [{ topic: "notifications" }], + }, + }, +}); diff --git a/examples/vercel-queues/package.json b/examples/vercel-queues/package.json new file mode 100644 index 0000000000..c55f7a5924 --- /dev/null +++ b/examples/vercel-queues/package.json @@ -0,0 +1,13 @@ +{ + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build" + }, + "devDependencies": { + "nitro": "latest" + }, + "dependencies": { + "@vercel/queue": "latest" + } +} diff --git a/examples/vercel-queues/plugins/queue.ts b/examples/vercel-queues/plugins/queue.ts new file mode 100644 index 0000000000..01072af765 --- /dev/null +++ b/examples/vercel-queues/plugins/queue.ts @@ -0,0 +1,12 @@ +import { runTask } from "nitro/task"; +import { definePlugin } from "nitro"; + +export default definePlugin((nitro) => { + nitro.hooks.hook("vercel:queue", async ({ message, metadata }) => { + if (metadata.topicName === "notifications") { + await runTask("notifications:send", { + payload: message as Record, + }); + } + }); +}); diff --git a/examples/vercel-queues/routes/send.ts b/examples/vercel-queues/routes/send.ts new file mode 100644 index 0000000000..2182667ae4 --- /dev/null +++ b/examples/vercel-queues/routes/send.ts @@ -0,0 +1,16 @@ +import { send } from "@vercel/queue"; +import { defineHandler, HTTPError } from "nitro"; + +export default defineHandler(async (event) => { + const body = await event.req.json() as Record; + if (!body.to || !body.subject || !body.body) { + throw new HTTPError({ status: 400, message: "Missing required fields `to`, `subject` or `body`" }); + } + + const { messageId } = await send("notifications", { + to: body.to, + subject: body.subject, + body: body.body, + }); + return { messageId }; +}); diff --git a/examples/vercel-queues/tasks/notifications/send.ts b/examples/vercel-queues/tasks/notifications/send.ts new file mode 100644 index 0000000000..9ac2a978f5 --- /dev/null +++ b/examples/vercel-queues/tasks/notifications/send.ts @@ -0,0 +1,11 @@ +import { defineTask } from "nitro/task"; + +export default defineTask({ + meta: { + description: "Send a notification", + }, + async run({ payload }) { + console.log(`Sending notification to ${payload.to}: ${payload.subject}`); + return { result: "Notification sent" }; + }, +}); diff --git a/examples/vercel-queues/tsconfig.json b/examples/vercel-queues/tsconfig.json new file mode 100644 index 0000000000..4b886bd47e --- /dev/null +++ b/examples/vercel-queues/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "nitro/tsconfig" +} diff --git a/examples/vercel-queues/vite.config.ts b/examples/vercel-queues/vite.config.ts new file mode 100644 index 0000000000..34d3353e1c --- /dev/null +++ b/examples/vercel-queues/vite.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from "vite"; +import { nitro } from "nitro/vite"; + +export default defineConfig({ plugins: [nitro()] }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dccd3e2fa1..20835c799f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -467,6 +467,16 @@ importers: specifier: latest version: 4.0.2 + examples/vercel-queues: + dependencies: + '@vercel/queue': + specifier: latest + version: 0.1.4 + devDependencies: + nitro: + specifier: link:../.. + version: link:../.. + examples/virtual-routes: devDependencies: nitro: From 0efd43b080f9ce4d7d7fdbf3e2d7ec4279880da1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:36:53 +0000 Subject: [PATCH 17/24] chore: apply automated updates --- docs/4.examples/vercel-queues.md | 172 ++++++++++++++++++++++++++ examples/vercel-queues/package.json | 6 +- examples/vercel-queues/routes/send.ts | 7 +- 3 files changed, 180 insertions(+), 5 deletions(-) diff --git a/docs/4.examples/vercel-queues.md b/docs/4.examples/vercel-queues.md index d9b4357b7a..f8582b693f 100644 --- a/docs/4.examples/vercel-queues.md +++ b/docs/4.examples/vercel-queues.md @@ -8,9 +8,181 @@ icon: i-simple-icons-vercel > Process background work asynchronously with Vercel Queues and Nitro tasks. + +::code-tree{defaultValue="nitro.config.ts" expandAll} + +```ts [nitro.config.ts] +import { defineConfig } from "nitro"; + +export default defineConfig({ + serverDir: ".", + experimental: { + tasks: true, + }, + vercel: { + queues: { + triggers: [{ topic: "notifications" }], + }, + }, +}); +``` + +```json [package.json] +{ + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build" + }, + "devDependencies": { + "nitro": "latest" + }, + "dependencies": { + "@vercel/queue": "latest" + } +} +``` + +```json [tsconfig.json] +{ + "extends": "nitro/tsconfig" +} +``` + +```ts [vite.config.ts] +import { defineConfig } from "vite"; +import { nitro } from "nitro/vite"; + +export default defineConfig({ plugins: [nitro()] }); +``` + +```ts [plugins/queue.ts] +import { runTask } from "nitro/task"; +import { definePlugin } from "nitro"; + +export default definePlugin((nitro) => { + nitro.hooks.hook("vercel:queue", async ({ message, metadata }) => { + if (metadata.topicName === "notifications") { + await runTask("notifications:send", { + payload: message as Record, + }); + } + }); +}); +``` + +```ts [routes/send.ts] +import { send } from "@vercel/queue"; +import { defineHandler, HTTPError } from "nitro"; + +export default defineHandler(async (event) => { + const body = await event.req.json() as Record; + if (!body.to || !body.subject || !body.body) { + throw new HTTPError({ status: 400, message: "Missing required fields `to`, `subject` or `body`" }); + } + + const { messageId } = await send("notifications", { + to: body.to, + subject: body.subject, + body: body.body, + }); + return { messageId }; +}); +``` + +```ts [tasks/notifications/send.ts] +import { defineTask } from "nitro/task"; + +export default defineTask({ + meta: { + description: "Send a notification", + }, + async run({ payload }) { + console.log(`Sending notification to ${payload.to}: ${payload.subject}`); + return { result: "Notification sent" }; + }, +}); +``` + +:: + + +Nitro integrates with [Vercel Queues](https://vercel.com/docs/queues) to process background work asynchronously. + +## Configuration + +Add queue triggers to your Nitro config. Nitro registers a queue consumer handler and makes incoming messages available via the `vercel:queue` runtime hook. + +```ts [nitro.config.ts] +import { defineConfig } from "nitro"; + +export default defineConfig({ + experimental: { + tasks: true, + }, + vercel: { + queues: { + triggers: [{ topic: "notifications" }], + }, + }, +}); +``` + +## Sending Messages + +Use the `@vercel/queue` SDK to send messages to a topic from any route. + +```ts [routes/send.ts] +import { send } from "@vercel/queue"; +import { defineHandler } from "nitro"; + +export default defineHandler(async (event) => { + const body = await event.req.json(); + const { messageId } = await send("notifications", { + to: body.to, + subject: body.subject, + body: body.body, + }); + return { messageId }; +}); +``` + +## Processing Messages + +Listen for the `vercel:queue` hook in a plugin to handle incoming messages. This example dispatches them to a Nitro task. + +```ts [plugins/queue.ts] +import { runTask } from "nitro/task"; +import { definePlugin } from "nitro"; + +export default definePlugin((nitro) => { + nitro.hooks.hook("vercel:queue", async ({ message, metadata }) => { + if (metadata.topicName === "notifications") { + await runTask("notifications:send", { + payload: message as Record, + }); + } + }); +}); +``` + +```ts [tasks/notifications/send.ts] +import { defineTask } from "nitro/task"; + +export default defineTask({ + meta: { + description: "Send a notification", + }, + async run({ payload }) { + console.log(`Sending notification to ${payload.to}: ${payload.subject}`); + return { result: "Notification sent" }; + }, +}); +``` + ## Learn More diff --git a/examples/vercel-queues/package.json b/examples/vercel-queues/package.json index c55f7a5924..472ae9a57c 100644 --- a/examples/vercel-queues/package.json +++ b/examples/vercel-queues/package.json @@ -4,10 +4,10 @@ "dev": "vite dev", "build": "vite build" }, - "devDependencies": { - "nitro": "latest" - }, "dependencies": { "@vercel/queue": "latest" + }, + "devDependencies": { + "nitro": "latest" } } diff --git a/examples/vercel-queues/routes/send.ts b/examples/vercel-queues/routes/send.ts index 2182667ae4..1d2f620581 100644 --- a/examples/vercel-queues/routes/send.ts +++ b/examples/vercel-queues/routes/send.ts @@ -2,9 +2,12 @@ import { send } from "@vercel/queue"; import { defineHandler, HTTPError } from "nitro"; export default defineHandler(async (event) => { - const body = await event.req.json() as Record; + const body = (await event.req.json()) as Record; if (!body.to || !body.subject || !body.body) { - throw new HTTPError({ status: 400, message: "Missing required fields `to`, `subject` or `body`" }); + throw new HTTPError({ + status: 400, + message: "Missing required fields `to`, `subject` or `body`", + }); } const { messageId } = await send("notifications", { From b73f763555c8f244ad818547d7ec3bca070b1925 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:37:32 +0000 Subject: [PATCH 18/24] chore: apply automated updates (attempt 2/3) --- docs/4.examples/vercel-queues.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/4.examples/vercel-queues.md b/docs/4.examples/vercel-queues.md index f8582b693f..5d0a5ccbfb 100644 --- a/docs/4.examples/vercel-queues.md +++ b/docs/4.examples/vercel-queues.md @@ -34,11 +34,11 @@ export default defineConfig({ "dev": "vite dev", "build": "vite build" }, - "devDependencies": { - "nitro": "latest" - }, "dependencies": { "@vercel/queue": "latest" + }, + "devDependencies": { + "nitro": "latest" } } ``` @@ -76,9 +76,12 @@ import { send } from "@vercel/queue"; import { defineHandler, HTTPError } from "nitro"; export default defineHandler(async (event) => { - const body = await event.req.json() as Record; + const body = (await event.req.json()) as Record; if (!body.to || !body.subject || !body.body) { - throw new HTTPError({ status: 400, message: "Missing required fields `to`, `subject` or `body`" }); + throw new HTTPError({ + status: 400, + message: "Missing required fields `to`, `subject` or `body`", + }); } const { messageId } = await send("notifications", { From c55c0e2763ed3757a0dfbebb0d8753df892d9828 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sat, 28 Mar 2026 00:40:50 +0100 Subject: [PATCH 19/24] fix avoid writing extra fn --- src/presets/vercel/utils.ts | 8 ++++++++ test/presets/vercel.test.ts | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index e7bd8991fc..c5de9d331b 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -86,6 +86,7 @@ export async function generateFunctionFiles(nitro: Nitro) { } // Write ISR functions + const isrFuncDirs = new Set(); for (const [key, value] of Object.entries(nitro.options.routeRules)) { if (!value.isr) { continue; @@ -100,6 +101,9 @@ export async function generateFunctionFiles(nitro: Nitro) { 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, @@ -131,6 +135,10 @@ export async function generateFunctionFiles(nitro: Nitro) { "..", 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, diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index b613f89c11..c9a537c4a1 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -476,7 +476,6 @@ describe("nitro:preset:vercel:web", async () => { "functions/rules/isr-ttl/[...]-isr.prerender-config.json", "functions/rules/isr/[...]-isr.func", "functions/rules/isr/[...]-isr.prerender-config.json", - "functions/rules/isr/[...].func", "functions/rules/swr-ttl/[...]-isr.func (symlink)", "functions/rules/swr-ttl/[...]-isr.prerender-config.json", "functions/rules/swr/[...]-isr.func (symlink)", From 5fe65f0004a492b67d9c7b1fe5f5535fff3223ff Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Fri, 27 Mar 2026 20:35:34 -0700 Subject: [PATCH 20/24] chore: rename to functionRules --- src/presets/vercel/preset.ts | 6 +++--- src/presets/vercel/types.ts | 4 ++-- src/presets/vercel/utils.ts | 18 +++++++++--------- test/fixture/nitro.config.ts | 2 +- test/presets/vercel.test.ts | 10 +++++----- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index b148d8b0f5..c27e8dd714 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -84,10 +84,10 @@ const vercel = defineNitroPreset( handler: join(presetsDir, "vercel/runtime/queue-handler"), }); - nitro.options.vercel!.routeFunctionConfig = { - ...nitro.options.vercel!.routeFunctionConfig, + nitro.options.vercel!.functionRules = { + ...nitro.options.vercel!.functionRules, [handlerRoute]: { - ...nitro.options.vercel!.routeFunctionConfig?.[handlerRoute], + ...nitro.options.vercel!.functionRules?.[handlerRoute], experimentalTriggers: queues.triggers.map((t) => ({ type: "queue/v2beta" as const, topic: t.topic, diff --git a/src/presets/vercel/types.ts b/src/presets/vercel/types.ts index 6f9e9537a8..fa4158f5c2 100644 --- a/src/presets/vercel/types.ts +++ b/src/presets/vercel/types.ts @@ -194,7 +194,7 @@ export interface VercelOptions { * * @example * ```ts - * routeFunctionConfig: { + * functionRules: { * '/api/my-slow-routes/**': { maxDuration: 3600 }, * '/api/queues/fulfill-order': { * experimentalTriggers: [{ type: 'queue/v2beta', topic: 'orders' }], @@ -202,7 +202,7 @@ export interface VercelOptions { * } * ``` */ - routeFunctionConfig?: Record; + functionRules?: Record; } /** diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index c5de9d331b..824ff7e736 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -64,20 +64,20 @@ export async function generateFunctionFiles(nitro: Nitro) { 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.routeFunctionConfig` to attach triggers to specific routes instead." + "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 routeFunctionConfig = nitro.options.vercel?.routeFunctionConfig; - const hasRouteFunctionConfig = routeFunctionConfig && Object.keys(routeFunctionConfig).length > 0; + const functionRules = nitro.options.vercel?.functionRules; + const hasRouteFunctionConfig = functionRules && Object.keys(functionRules).length > 0; let routeFuncRouter: Router | undefined; if (hasRouteFunctionConfig) { routeFuncRouter = new Router(); routeFuncRouter._update( - Object.entries(routeFunctionConfig).map(([route, data]) => ({ + Object.entries(functionRules).map(([route, data]) => ({ route, method: "", data, @@ -126,10 +126,10 @@ export async function generateFunctionFiles(nitro: Nitro) { ); } - // Write routeFunctionConfig custom function directories + // Write functionRules custom function directories const createdFuncDirs = new Set(); if (hasRouteFunctionConfig) { - for (const [pattern, overrides] of Object.entries(routeFunctionConfig!)) { + for (const [pattern, overrides] of Object.entries(functionRules!)) { const funcDir = resolve( nitro.options.output.serverDir, "..", @@ -164,7 +164,7 @@ export async function generateFunctionFiles(nitro: Nitro) { const funcPrefix = resolve(nitro.options.output.serverDir, "..", route.dest); const funcDir = funcPrefix + ".func"; - // Skip if already created by routeFunctionConfig + // Skip if already created by functionRules if (createdFuncDirs.has(funcDir)) { continue; } @@ -360,8 +360,8 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { }; }), // Route function config routes - ...(nitro.options.vercel?.routeFunctionConfig - ? Object.keys(nitro.options.vercel.routeFunctionConfig).map((pattern) => ({ + ...(nitro.options.vercel?.functionRules + ? Object.keys(nitro.options.vercel.functionRules).map((pattern) => ({ src: joinURL(nitro.options.baseURL, normalizeRouteSrc(pattern)), dest: withLeadingSlash(normalizeRouteDest(pattern)), })) diff --git a/test/fixture/nitro.config.ts b/test/fixture/nitro.config.ts index ba3bd84d67..197a9da1de 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -5,7 +5,7 @@ import { existsSync } from "node:fs"; export default defineConfig({ vercel: { - routeFunctionConfig: { + functionRules: { "/api/hello": { maxDuration: 100, }, diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index 31041ebfc3..b3ab390b08 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -491,14 +491,14 @@ describe("nitro:preset:vercel:web", async () => { `); }); - it("should create custom function directory for routeFunctionConfig (not symlink)", 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 routeFunctionConfig overrides", async () => { + 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)); @@ -508,7 +508,7 @@ describe("nitro:preset:vercel:web", async () => { expect(config.supportsResponseStreaming).toBe(true); }); - it("should write routeFunctionConfig with arbitrary fields", async () => { + 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)); @@ -518,13 +518,13 @@ describe("nitro:preset:vercel:web", async () => { expect(config.handler).toBe("index.mjs"); }); - it("should copy files inside routeFunctionConfig directory from __server.func", async () => { + 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 routeFunctionConfig overrides", async () => { + 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)); From ffba3075c72bcab5fedde319bacc46b68bdfff29 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Mon, 30 Mar 2026 12:34:04 +0100 Subject: [PATCH 21/24] chore: add @vercel/queue --- package.json | 1 + pnpm-lock.yaml | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index e83f50178f..cdbafb263e 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "@types/semver": "^7.7.1", "@types/xml2js": "^0.4.14", "@typescript/native-preview": "latest", + "@vercel/queue": "^0.1.4", "@vitest/coverage-v8": "^4.1.2", "automd": "^0.4.3", "c12": "^4.0.0-beta.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 703e33403d..699c7a0a0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,9 @@ importers: '@typescript/native-preview': specifier: latest version: 7.0.0-dev.20260329.1 + '@vercel/queue': + specifier: ^0.1.4 + version: 0.1.4 '@vitest/coverage-v8': specifier: ^4.1.2 version: 4.1.2(vitest@4.1.2(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) @@ -464,6 +467,16 @@ importers: specifier: latest version: 4.0.2 + examples/vercel-queues: + dependencies: + '@vercel/queue': + specifier: latest + version: 0.1.4 + devDependencies: + nitro: + specifier: link:../.. + version: link:../.. + examples/virtual-routes: devDependencies: nitro: @@ -3184,6 +3197,10 @@ packages: resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} + '@vercel/queue@0.1.4': + resolution: {integrity: sha512-wo+jCycmCX078vQSbkX+RcLvySONDCK0f9aQp5UMKQD1+B+xKt3YVbIYbZukvoHQpbm5nnk6If+ADSeK/PmCgQ==} + engines: {node: '>=20.0.0'} + '@vitejs/plugin-react@6.0.1': resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5344,6 +5361,10 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mixpart@0.0.5: + resolution: {integrity: sha512-TpWi9/2UIr7VWCVAM7NB4WR4yOglAetBkuKfxs3K0vFcUukqAaW1xsgX0v1gNGiDKzYhPHFcHgarC7jmnaOy4w==} + engines: {node: '>=20.0.0'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -9573,6 +9594,13 @@ snapshots: '@vercel/oidc@3.1.0': {} + '@vercel/queue@0.1.4': + dependencies: + '@vercel/oidc': 3.1.0 + minimatch: 10.2.4 + mixpart: 0.0.5 + picocolors: 1.1.1 + '@vitejs/plugin-react@6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 @@ -9913,7 +9941,7 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 - axios-retry@4.5.0(axios@1.14.0): + axios-retry@4.5.0(axios@1.14.0(debug@4.4.3)): dependencies: axios: 1.14.0(debug@4.4.3) is-retry-allowed: 2.2.0 @@ -11981,6 +12009,8 @@ snapshots: minipass@7.1.3: {} + mixpart@0.0.5: {} + mkdirp-classic@0.5.3: {} mkdirp@0.5.6: @@ -13844,7 +13874,7 @@ snapshots: dependencies: '@toon-format/toon': 0.9.0 axios: 1.14.0(debug@4.4.3) - axios-retry: 4.5.0(axios@1.14.0) + axios-retry: 4.5.0(axios@1.14.0(debug@4.4.3)) debug: 4.4.3 eventsource: 4.1.0 git-url-parse: 15.0.0 From 9fd1b1aa23410155c16543e73f5ba321de6c169d Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Mon, 30 Mar 2026 12:35:25 +0100 Subject: [PATCH 22/24] Delete examples/vercel-queues/.gitignore --- examples/vercel-queues/.gitignore | 1 - 1 file changed, 1 deletion(-) delete mode 100644 examples/vercel-queues/.gitignore diff --git a/examples/vercel-queues/.gitignore b/examples/vercel-queues/.gitignore deleted file mode 100644 index e985853ed8..0000000000 --- a/examples/vercel-queues/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.vercel From e1ce4f158fb7785037d9c1b8b6a44e8ed6ca3cb2 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Mon, 30 Mar 2026 15:00:30 +0100 Subject: [PATCH 23/24] chore: move to @vercel/queue dev deps --- examples/vercel-queues/package.json | 4 +--- pnpm-lock.yaml | 5 ++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/examples/vercel-queues/package.json b/examples/vercel-queues/package.json index 472ae9a57c..dedb4036d7 100644 --- a/examples/vercel-queues/package.json +++ b/examples/vercel-queues/package.json @@ -4,10 +4,8 @@ "dev": "vite dev", "build": "vite build" }, - "dependencies": { - "@vercel/queue": "latest" - }, "devDependencies": { + "@vercel/queue": "^0.1.4", "nitro": "latest" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 699c7a0a0f..4b3cccd777 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -468,11 +468,10 @@ importers: version: 4.0.2 examples/vercel-queues: - dependencies: + devDependencies: '@vercel/queue': - specifier: latest + specifier: ^0.1.4 version: 0.1.4 - devDependencies: nitro: specifier: link:../.. version: link:../.. From 1f6cdf2349c17c2d38a8545bda8eaf90d43cbec8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:01:38 +0000 Subject: [PATCH 24/24] chore: apply automated updates --- docs/4.examples/vercel-queues.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/4.examples/vercel-queues.md b/docs/4.examples/vercel-queues.md index 5d0a5ccbfb..35197a4c7d 100644 --- a/docs/4.examples/vercel-queues.md +++ b/docs/4.examples/vercel-queues.md @@ -34,10 +34,8 @@ export default defineConfig({ "dev": "vite dev", "build": "vite build" }, - "dependencies": { - "@vercel/queue": "latest" - }, "devDependencies": { + "@vercel/queue": "^0.1.4", "nitro": "latest" } }