diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index 305438101f..7d19af9884 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/docs/4.examples/vercel-queues.md b/docs/4.examples/vercel-queues.md new file mode 100644 index 0000000000..f4436dc351 --- /dev/null +++ b/docs/4.examples/vercel-queues.md @@ -0,0 +1,193 @@ +--- +category: deploy +icon: i-simple-icons-vercel +--- + +# Vercel Queues + +> 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": { + "@vercel/queue": "^0.1.4", + "nitro": "latest", + "vite": "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 + +- [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..4e88afda95 --- /dev/null +++ b/examples/vercel-queues/package.json @@ -0,0 +1,12 @@ +{ + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build" + }, + "devDependencies": { + "@vercel/queue": "^0.1.4", + "nitro": "latest", + "vite": "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..1d2f620581 --- /dev/null +++ b/examples/vercel-queues/routes/send.ts @@ -0,0 +1,19 @@ +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/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..52ec051dce 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,18 @@ importers: specifier: latest version: 4.0.2 + examples/vercel-queues: + devDependencies: + '@vercel/queue': + specifier: ^0.1.4 + version: 0.1.4 + nitro: + specifier: link:../.. + version: link:../.. + vite: + specifier: latest + version: 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) + examples/virtual-routes: devDependencies: nitro: @@ -3184,6 +3199,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 +5363,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 +9596,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 +9943,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 +12011,8 @@ snapshots: minipass@7.1.3: {} + mixpart@0.0.5: {} + mkdirp-classic@0.5.3: {} mkdirp@0.5.6: @@ -13844,7 +13876,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 diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index a24420f44b..c27e8dd714 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!.functionRules = { + ...nitro.options.vercel!.functionRules, + [handlerRoute]: { + ...nitro.options.vercel!.functionRules?.[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..f4afd28b0d --- /dev/null +++ b/src/presets/vercel/runtime/queue-handler.ts @@ -0,0 +1,9 @@ +import { handleCallback } from "@vercel/queue"; +import { defineHandler } from "nitro"; +import { useNitroHooks } from "nitro/app"; + +export default defineHandler((event) => { + return handleCallback(async (message, metadata) => { + await useNitroHooks().callHook("vercel:queue", { message, metadata }); + })(event.req as Request); +}); diff --git a/src/presets/vercel/types.ts b/src/presets/vercel/types.ts index e86904c254..fa4158f5c2 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/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index b25382561a..e8ebc2a529 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -72,9 +72,9 @@ export async function generateFunctionFiles(nitro: Nitro) { await writeFile(functionConfigPath, JSON.stringify(baseFunctionConfig, null, 2)); const functionRules = nitro.options.vercel?.functionRules; - const hasfunctionRules = functionRules && Object.keys(functionRules).length > 0; + const hasFunctionRules = functionRules && Object.keys(functionRules).length > 0; let routeFuncRouter: Router | undefined; - if (hasfunctionRules) { + if (hasFunctionRules) { routeFuncRouter = new Router(); routeFuncRouter._update( Object.entries(functionRules).map(([route, data]) => ({ @@ -128,7 +128,7 @@ export async function generateFunctionFiles(nitro: Nitro) { // Write functionRules custom function directories const createdFuncDirs = new Set(); - if (hasfunctionRules) { + if (hasFunctionRules) { for (const [pattern, overrides] of Object.entries(functionRules!)) { const funcDir = resolve( nitro.options.output.serverDir, diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index d6327043a0..b3ab390b08 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -584,6 +584,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");