Skip to content

Commit 11d292e

Browse files
committed
feat(vercel): queues (#4127)
1 parent 689788e commit 11d292e

8 files changed

Lines changed: 263 additions & 4 deletions

File tree

docs/2.deploy/20.providers/vercel.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,68 @@ export default defineConfig({
148148

149149
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.
150150

151+
## Queues
152+
153+
:read-more{title="Vercel Queues" to="https://vercel.com/docs/queues"}
154+
155+
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.
156+
157+
```ts [nitro.config.ts]
158+
export default defineNitroConfig({
159+
vercel: {
160+
queues: {
161+
triggers: [
162+
// Only `topic` is required
163+
{ topic: "notifications" },
164+
{ topic: "orders", retryAfterSeconds: 60, initialDelaySeconds: 5 },
165+
],
166+
},
167+
},
168+
});
169+
```
170+
171+
### Handling messages
172+
173+
Use the `vercel:queue` hook in a [Nitro plugin](/guide/plugins) to process incoming queue messages:
174+
175+
```ts [server/plugins/queues.ts]
176+
export default defineNitroPlugin((nitro) => {
177+
nitro.hooks.hook("vercel:queue", ({ message, metadata, send }) => {
178+
console.log(`[${metadata.topicName}] Message ${metadata.messageId}:`, message);
179+
});
180+
});
181+
```
182+
183+
### Running tasks from queue messages
184+
185+
You can use queue messages to trigger [Nitro tasks](/docs/tasks):
186+
187+
```ts [server/plugins/queues.ts]
188+
import { runTask } from "nitro/task";
189+
190+
export default defineNitroPlugin((nitro) => {
191+
nitro.hooks.hook("vercel:queue", async ({ message, metadata }) => {
192+
if (metadata.topicName === "orders") {
193+
await runTask("orders:fulfill", { payload: message });
194+
}
195+
});
196+
});
197+
```
198+
199+
### Sending messages
200+
201+
Use the `@vercel/queue` package directly to send messages to a topic:
202+
203+
```ts [server/routes/api/orders.post.ts]
204+
import { send } from "@vercel/queue";
205+
206+
export default defineEventHandler(async (event) => {
207+
const order = await event.req.json();
208+
const { messageId } = await send("orders", order);
209+
return { messageId };
210+
});
211+
```
212+
151213
## Custom build output configuration
152214

153215
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.

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
"@types/semver": "^7.7.1",
108108
"@types/xml2js": "^0.4.14",
109109
"@typescript/native-preview": "^7.0.0-dev.20260413.1",
110+
"@vercel/queue": "^0.1.4",
110111
"@vitest/coverage-v8": "^4.1.4",
111112
"automd": "^0.4.3",
112113
"c12": "^4.0.0-beta.4",
@@ -174,6 +175,7 @@
174175
"zephyr-agent": "^1.0.1"
175176
},
176177
"peerDependencies": {
178+
"@vercel/queue": "^0.1.4",
177179
"dotenv": "*",
178180
"giget": "*",
179181
"jiti": "^2.6.1",
@@ -183,6 +185,9 @@
183185
"zephyr-agent": "^0.2.0"
184186
},
185187
"peerDependenciesMeta": {
188+
"@vercel/queue": {
189+
"optional": true
190+
},
186191
"dotenv": {
187192
"optional": true
188193
},

pnpm-lock.yaml

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/presets/vercel/preset.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import { defineNitroPreset } from "../_utils/preset.ts";
22
import type { Nitro } from "nitro/types";
33
import { presetsDir } from "nitro/meta";
44
import { join } from "pathe";
5+
import { importDep } from "../../utils/dep.ts";
56
import {
67
deprecateSWR,
78
generateFunctionFiles,
89
generateStaticFiles,
910
resolveVercelRuntime,
1011
} from "./utils.ts";
1112

13+
import type { VercelFunctionTrigger } from "./types.ts";
14+
1215
export type { VercelOptions as PresetOptions } from "./types.ts";
1316

1417
// https://vercel.com/docs/build-output-api/v3
@@ -65,6 +68,35 @@ const vercel = defineNitroPreset(
6568
handler: join(presetsDir, "vercel/runtime/cron-handler"),
6669
});
6770
}
71+
72+
// Queue consumer handler
73+
const queues = nitro.options.vercel?.queues;
74+
if (queues?.triggers?.length) {
75+
await importDep({
76+
id: "@vercel/queue",
77+
dir: nitro.options.rootDir,
78+
reason: "Vercel Queues",
79+
});
80+
81+
const handlerRoute = queues.handlerRoute || "/_vercel/queues/consumer";
82+
83+
nitro.options.handlers.push({
84+
route: handlerRoute,
85+
lazy: true,
86+
handler: join(presetsDir, "vercel/runtime/queue-handler"),
87+
});
88+
89+
const queueTriggers: VercelFunctionTrigger[] = queues.triggers.map(
90+
({ topic, ...opts }) => ({ type: "queue/v2beta", topic, ...opts })
91+
);
92+
nitro.options.vercel ??= {};
93+
nitro.options.vercel.functionRules ??= {};
94+
const existingRule = nitro.options.vercel.functionRules[handlerRoute];
95+
nitro.options.vercel.functionRules[handlerRoute] = {
96+
...existingRule,
97+
experimentalTriggers: [...(existingRule?.experimentalTriggers || []), ...queueTriggers],
98+
};
99+
}
68100
},
69101
"rollup:before": (nitro: Nitro) => {
70102
deprecateSWR(nitro);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { handleCallback, send } from "@vercel/queue";
2+
import { defineHandler } from "nitro";
3+
import { useNitroApp, useNitroHooks } from "nitro/app";
4+
5+
export default defineHandler((event) => {
6+
return handleCallback(async (message, metadata) => {
7+
try {
8+
await useNitroHooks().callHook("vercel:queue", { message, metadata, send });
9+
} catch (error) {
10+
console.error("[vercel:queue]", error);
11+
useNitroApp().captureError?.(error as Error, { event, tags: ["vercel:queue"] });
12+
throw error;
13+
}
14+
})(event.req as Request);
15+
});

src/presets/vercel/types.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { send } from "@vercel/queue";
2+
13
/**
24
* Vercel Build Output Configuration
35
* @see https://vercel.com/docs/build-output-api/v3
@@ -105,9 +107,22 @@ export interface VercelServerlessFunctionConfig {
105107
*/
106108
runtime?: "nodejs20.x" | "nodejs22.x" | "bun1.x" | (string & {});
107109

110+
/**
111+
* Experimental trigger configuration (e.g., Vercel Queues).
112+
*/
113+
experimentalTriggers?: VercelFunctionTrigger[];
114+
108115
[key: string]: unknown;
109116
}
110117

118+
export type VercelFunctionTrigger = {
119+
type: "queue/v2beta";
120+
topic: string;
121+
retryAfterSeconds?: number;
122+
initialDelaySeconds?: number;
123+
consumer?: string;
124+
};
125+
111126
export interface VercelOptions {
112127
config?: VercelBuildConfigV3;
113128

@@ -148,6 +163,48 @@ export interface VercelOptions {
148163
*/
149164
cronHandlerRoute?: string;
150165

166+
/**
167+
* Vercel Queues configuration.
168+
*
169+
* Messages are delivered via the `vercel:queue` runtime hook.
170+
*
171+
* @example
172+
* ```ts
173+
* // nitro.config.ts
174+
* export default defineNitroConfig({
175+
* vercel: {
176+
* queues: {
177+
* triggers: [{ topic: "orders" }],
178+
* },
179+
* },
180+
* });
181+
* ```
182+
*
183+
* ```ts
184+
* // server/plugins/queues.ts
185+
* export default defineNitroPlugin((nitro) => {
186+
* nitro.hooks.hook("vercel:queue", ({ message, metadata }) => {
187+
* console.log(`Received message on ${metadata.topicName}:`, message);
188+
* });
189+
* });
190+
* ```
191+
*
192+
* @see https://vercel.com/docs/queues
193+
*/
194+
queues?: {
195+
/**
196+
* Route path for the queue consumer handler.
197+
* @default "/_vercel/queues/consumer"
198+
*/
199+
handlerRoute?: string;
200+
/** Queue topic triggers to subscribe to. */
201+
triggers: Array<{
202+
topic: string;
203+
retryAfterSeconds?: number;
204+
initialDelaySeconds?: number;
205+
}>;
206+
};
207+
151208
/**
152209
* Per-route function configuration overrides.
153210
*
@@ -206,3 +263,13 @@ export type PrerenderFunctionConfig = {
206263
*/
207264
exposeErrBody?: boolean;
208265
};
266+
267+
declare module "nitro/types" {
268+
export interface NitroRuntimeHooks {
269+
"vercel:queue": (_: {
270+
message: unknown;
271+
metadata: import("@vercel/queue").MessageMetadata;
272+
send: typeof send;
273+
}) => void;
274+
}
275+
}

src/presets/vercel/utils.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export async function generateFunctionFiles(nitro: Nitro) {
6363
) {
6464
nitro.logger.warn(
6565
"`experimentalTriggers` on the base `vercel.functions` config applies to the catch-all function and is likely not what you want. " +
66-
"Routes with queue triggers are not accesible on the web." +
66+
"Routes with queue triggers are not accessible on the web. " +
6767
"Use `vercel.functionRules` to attach triggers to specific routes instead."
6868
);
6969
}
@@ -72,9 +72,9 @@ export async function generateFunctionFiles(nitro: Nitro) {
7272
await writeFile(functionConfigPath, JSON.stringify(baseFunctionConfig, null, 2));
7373

7474
const functionRules = nitro.options.vercel?.functionRules;
75-
const hasfunctionRules = functionRules && Object.keys(functionRules).length > 0;
75+
const hasRouteFunctionConfig = functionRules && Object.keys(functionRules).length > 0;
7676
let routeFuncRouter: Router<VercelServerlessFunctionConfig> | undefined;
77-
if (hasfunctionRules) {
77+
if (hasRouteFunctionConfig) {
7878
routeFuncRouter = new Router<VercelServerlessFunctionConfig>();
7979
routeFuncRouter._update(
8080
Object.entries(functionRules).map(([route, data]) => ({
@@ -128,7 +128,7 @@ export async function generateFunctionFiles(nitro: Nitro) {
128128

129129
// Write functionRules custom function directories
130130
const createdFuncDirs = new Set<string>();
131-
if (hasfunctionRules) {
131+
if (hasRouteFunctionConfig) {
132132
for (const [pattern, overrides] of Object.entries(functionRules!)) {
133133
const funcDir = resolve(
134134
nitro.options.output.serverDir,

0 commit comments

Comments
 (0)