diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index e835edf324..61b77c9367 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -119,6 +119,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 9ae7d3ce2d..8acdbed3a2 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.0", "automd": "^0.4.3", "c12": "^4.0.0-beta.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d2884281d..7e01cec321 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,7 +103,7 @@ importers: version: 6.0.3(rollup@4.59.0) '@scalar/api-reference': specifier: ^1.48.7 - version: 1.48.7(axios@1.13.6(debug@4.4.3))(jwt-decode@4.0.0)(tailwindcss@4.2.1)(typescript@5.9.3) + version: 1.48.7(axios@1.13.6)(jwt-decode@4.0.0)(tailwindcss@4.2.1)(typescript@5.9.3) '@types/aws-lambda': specifier: ^8.10.161 version: 8.10.161 @@ -131,6 +131,9 @@ importers: '@typescript/native-preview': specifier: latest version: 7.0.0-dev.20260314.1 + '@vercel/queue': + specifier: ^0.1.4 + version: 0.1.4 '@vitest/coverage-v8': specifier: ^4.1.0 version: 4.1.0(vitest@4.1.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))) @@ -3070,6 +3073,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@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5244,6 +5251,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==} @@ -8421,10 +8432,10 @@ snapshots: '@sagold/json-pointer': 5.1.2 ebnf: 1.9.1 - '@scalar/agent-chat@0.9.7(axios@1.13.6(debug@4.4.3))(jwt-decode@4.0.0)(tailwindcss@4.2.1)(typescript@5.9.3)': + '@scalar/agent-chat@0.9.7(axios@1.13.6)(jwt-decode@4.0.0)(tailwindcss@4.2.1)(typescript@5.9.3)': dependencies: '@ai-sdk/vue': 3.0.33(vue@3.5.30(typescript@5.9.3))(zod@4.3.6) - '@scalar/api-client': 2.37.0(axios@1.13.6(debug@4.4.3))(jwt-decode@4.0.0)(tailwindcss@4.2.1)(typescript@5.9.3) + '@scalar/api-client': 2.37.0(axios@1.13.6)(jwt-decode@4.0.0)(tailwindcss@4.2.1)(typescript@5.9.3) '@scalar/components': 0.20.7(typescript@5.9.3) '@scalar/helpers': 0.4.1 '@scalar/icons': 0.6.0(typescript@5.9.3) @@ -8461,7 +8472,7 @@ snapshots: dependencies: zod: 4.3.6 - '@scalar/api-client@2.37.0(axios@1.13.6(debug@4.4.3))(jwt-decode@4.0.0)(tailwindcss@4.2.1)(typescript@5.9.3)': + '@scalar/api-client@2.37.0(axios@1.13.6)(jwt-decode@4.0.0)(tailwindcss@4.2.1)(typescript@5.9.3)': dependencies: '@headlessui/tailwindcss': 0.2.2(tailwindcss@4.2.1) '@headlessui/vue': 1.7.23(vue@3.5.30(typescript@5.9.3)) @@ -8488,7 +8499,7 @@ snapshots: '@scalar/workspace-store': 0.40.0(typescript@5.9.3) '@types/har-format': 1.2.16 '@vueuse/core': 13.9.0(vue@3.5.30(typescript@5.9.3)) - '@vueuse/integrations': 13.9.0(axios@1.13.6(debug@4.4.3))(focus-trap@7.8.0)(fuse.js@7.1.0)(jwt-decode@4.0.0)(vue@3.5.30(typescript@5.9.3)) + '@vueuse/integrations': 13.9.0(axios@1.13.6)(focus-trap@7.8.0)(fuse.js@7.1.0)(jwt-decode@4.0.0)(vue@3.5.30(typescript@5.9.3)) focus-trap: 7.8.0 fuse.js: 7.1.0 js-base64: 3.7.8 @@ -8522,11 +8533,11 @@ snapshots: - typescript - universal-cookie - '@scalar/api-reference@1.48.7(axios@1.13.6(debug@4.4.3))(jwt-decode@4.0.0)(tailwindcss@4.2.1)(typescript@5.9.3)': + '@scalar/api-reference@1.48.7(axios@1.13.6)(jwt-decode@4.0.0)(tailwindcss@4.2.1)(typescript@5.9.3)': dependencies: '@headlessui/vue': 1.7.23(vue@3.5.30(typescript@5.9.3)) - '@scalar/agent-chat': 0.9.7(axios@1.13.6(debug@4.4.3))(jwt-decode@4.0.0)(tailwindcss@4.2.1)(typescript@5.9.3) - '@scalar/api-client': 2.37.0(axios@1.13.6(debug@4.4.3))(jwt-decode@4.0.0)(tailwindcss@4.2.1)(typescript@5.9.3) + '@scalar/agent-chat': 0.9.7(axios@1.13.6)(jwt-decode@4.0.0)(tailwindcss@4.2.1)(typescript@5.9.3) + '@scalar/api-client': 2.37.0(axios@1.13.6)(jwt-decode@4.0.0)(tailwindcss@4.2.1)(typescript@5.9.3) '@scalar/code-highlight': 0.3.0 '@scalar/components': 0.20.7(typescript@5.9.3) '@scalar/helpers': 0.4.1 @@ -9354,6 +9365,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@5.2.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 @@ -9515,7 +9533,7 @@ snapshots: '@vueuse/shared': 13.9.0(vue@3.5.30(typescript@5.9.3)) vue: 3.5.30(typescript@5.9.3) - '@vueuse/integrations@13.9.0(axios@1.13.6(debug@4.4.3))(focus-trap@7.8.0)(fuse.js@7.1.0)(jwt-decode@4.0.0)(vue@3.5.30(typescript@5.9.3))': + '@vueuse/integrations@13.9.0(axios@1.13.6)(focus-trap@7.8.0)(fuse.js@7.1.0)(jwt-decode@4.0.0)(vue@3.5.30(typescript@5.9.3))': dependencies: '@vueuse/core': 13.9.0(vue@3.5.30(typescript@5.9.3)) '@vueuse/shared': 13.9.0(vue@3.5.30(typescript@5.9.3)) @@ -9668,7 +9686,7 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 - axios-retry@4.5.0(axios@1.13.6(debug@4.4.3)): + axios-retry@4.5.0(axios@1.13.6): dependencies: axios: 1.13.6(debug@4.4.3) is-retry-allowed: 2.2.0 @@ -11768,6 +11786,8 @@ snapshots: minipass@7.1.3: {} + mixpart@0.0.5: {} + mkdirp-classic@0.5.3: {} mkdirp@0.5.6: @@ -13573,7 +13593,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(debug@4.4.3)) + axios-retry: 4.5.0(axios@1.13.6) 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 e5797fcfe0..001eca4441 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -603,6 +603,47 @@ describe("nitro:preset:vercel:route-function-config", 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");