From 3a82754521a8dac393ca967a2a31862d331b8180 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 13 May 2026 20:02:36 +0100 Subject: [PATCH] feat(vercel): support queues in local dev --- docs/2.deploy/20.providers/vercel.md | 6 +++ src/presets/_types.gen.ts | 4 +- src/presets/vercel/dev.ts | 44 +++++++++++++++++++ src/presets/vercel/preset.ts | 16 ++++++- src/presets/vercel/runtime/queue.dev.ts | 57 +++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 src/presets/vercel/dev.ts create mode 100644 src/presets/vercel/runtime/queue.dev.ts diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index d6dd0ef827..52e604a9df 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -210,6 +210,12 @@ export default defineEventHandler(async (event) => { }); ``` +### Local development + +Queues work in `nitro dev` — `send()` delivers messages straight to your `vercel:queue` hook, so you can iterate without deploying. Pull your Vercel environment first with `vercel link` and `vercel env pull` so the SDK can authenticate. + +If your hook throws, the message is retried locally. Retries honour `retryAfterSeconds` from each trigger when set. + ## 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/src/presets/_types.gen.ts b/src/presets/_types.gen.ts index 11151d5c5c..680db45ca4 100644 --- a/src/presets/_types.gen.ts +++ b/src/presets/_types.gen.ts @@ -22,6 +22,6 @@ export interface PresetOptions { export const presetsWithConfig = ["awsAmplify","awsLambda","azure","cloudflare","firebase","netlify","vercel","zephyr"] as const; -export type PresetName = "alwaysdata" | "aws-amplify" | "aws-lambda" | "azure-swa" | "base-worker" | "bun" | "cleavr" | "cloudflare-dev" | "cloudflare-durable" | "cloudflare-module" | "cloudflare-pages" | "cloudflare-pages-static" | "deno" | "deno-deploy" | "deno-server" | "digital-ocean" | "edgeone" | "edgeone-pages" | "firebase-app-hosting" | "flight-control" | "genezio" | "github-pages" | "gitlab-pages" | "heroku" | "iis-handler" | "iis-node" | "koyeb" | "netlify" | "netlify-edge" | "netlify-static" | "nitro-dev" | "nitro-prerender" | "node" | "node-cluster" | "node-middleware" | "node-server" | "platform-sh" | "render-com" | "standard" | "static" | "stormkit" | "vercel" | "vercel-static" | "winterjs" | "zeabur" | "zeabur-static" | "zephyr" | "zerops" | "zerops-static"; +export type PresetName = "alwaysdata" | "aws-amplify" | "aws-lambda" | "azure-swa" | "base-worker" | "bun" | "cleavr" | "cloudflare-dev" | "cloudflare-durable" | "cloudflare-module" | "cloudflare-pages" | "cloudflare-pages-static" | "deno" | "deno-deploy" | "deno-server" | "digital-ocean" | "edgeone" | "edgeone-pages" | "firebase-app-hosting" | "flight-control" | "genezio" | "github-pages" | "gitlab-pages" | "heroku" | "iis-handler" | "iis-node" | "koyeb" | "netlify" | "netlify-edge" | "netlify-static" | "nitro-dev" | "nitro-prerender" | "node" | "node-cluster" | "node-middleware" | "node-server" | "platform-sh" | "render-com" | "standard" | "static" | "stormkit" | "vercel" | "vercel-dev" | "vercel-static" | "winterjs" | "zeabur" | "zeabur-static" | "zephyr" | "zerops" | "zerops-static"; -export type PresetNameInput = "alwaysdata" | "aws-amplify" | "awsAmplify" | "aws_amplify" | "aws-lambda" | "awsLambda" | "aws_lambda" | "azure-swa" | "azureSwa" | "azure_swa" | "base-worker" | "baseWorker" | "base_worker" | "bun" | "cleavr" | "cloudflare-dev" | "cloudflareDev" | "cloudflare_dev" | "cloudflare-durable" | "cloudflareDurable" | "cloudflare_durable" | "cloudflare-module" | "cloudflareModule" | "cloudflare_module" | "cloudflare-pages" | "cloudflarePages" | "cloudflare_pages" | "cloudflare-pages-static" | "cloudflarePagesStatic" | "cloudflare_pages_static" | "deno" | "deno-deploy" | "denoDeploy" | "deno_deploy" | "deno-server" | "denoServer" | "deno_server" | "digital-ocean" | "digitalOcean" | "digital_ocean" | "edgeone" | "edgeone-pages" | "edgeonePages" | "edgeone_pages" | "firebase-app-hosting" | "firebaseAppHosting" | "firebase_app_hosting" | "flight-control" | "flightControl" | "flight_control" | "genezio" | "github-pages" | "githubPages" | "github_pages" | "gitlab-pages" | "gitlabPages" | "gitlab_pages" | "heroku" | "iis-handler" | "iisHandler" | "iis_handler" | "iis-node" | "iisNode" | "iis_node" | "koyeb" | "netlify" | "netlify-edge" | "netlifyEdge" | "netlify_edge" | "netlify-static" | "netlifyStatic" | "netlify_static" | "nitro-dev" | "nitroDev" | "nitro_dev" | "nitro-prerender" | "nitroPrerender" | "nitro_prerender" | "node" | "node-cluster" | "nodeCluster" | "node_cluster" | "node-middleware" | "nodeMiddleware" | "node_middleware" | "node-server" | "nodeServer" | "node_server" | "platform-sh" | "platformSh" | "platform_sh" | "render-com" | "renderCom" | "render_com" | "standard" | "static" | "stormkit" | "vercel" | "vercel-static" | "vercelStatic" | "vercel_static" | "winterjs" | "zeabur" | "zeabur-static" | "zeaburStatic" | "zeabur_static" | "zephyr" | "zerops" | "zerops-static" | "zeropsStatic" | "zerops_static" | (string & {}); +export type PresetNameInput = "alwaysdata" | "aws-amplify" | "awsAmplify" | "aws_amplify" | "aws-lambda" | "awsLambda" | "aws_lambda" | "azure-swa" | "azureSwa" | "azure_swa" | "base-worker" | "baseWorker" | "base_worker" | "bun" | "cleavr" | "cloudflare-dev" | "cloudflareDev" | "cloudflare_dev" | "cloudflare-durable" | "cloudflareDurable" | "cloudflare_durable" | "cloudflare-module" | "cloudflareModule" | "cloudflare_module" | "cloudflare-pages" | "cloudflarePages" | "cloudflare_pages" | "cloudflare-pages-static" | "cloudflarePagesStatic" | "cloudflare_pages_static" | "deno" | "deno-deploy" | "denoDeploy" | "deno_deploy" | "deno-server" | "denoServer" | "deno_server" | "digital-ocean" | "digitalOcean" | "digital_ocean" | "edgeone" | "edgeone-pages" | "edgeonePages" | "edgeone_pages" | "firebase-app-hosting" | "firebaseAppHosting" | "firebase_app_hosting" | "flight-control" | "flightControl" | "flight_control" | "genezio" | "github-pages" | "githubPages" | "github_pages" | "gitlab-pages" | "gitlabPages" | "gitlab_pages" | "heroku" | "iis-handler" | "iisHandler" | "iis_handler" | "iis-node" | "iisNode" | "iis_node" | "koyeb" | "netlify" | "netlify-edge" | "netlifyEdge" | "netlify_edge" | "netlify-static" | "netlifyStatic" | "netlify_static" | "nitro-dev" | "nitroDev" | "nitro_dev" | "nitro-prerender" | "nitroPrerender" | "nitro_prerender" | "node" | "node-cluster" | "nodeCluster" | "node_cluster" | "node-middleware" | "nodeMiddleware" | "node_middleware" | "node-server" | "nodeServer" | "node_server" | "platform-sh" | "platformSh" | "platform_sh" | "render-com" | "renderCom" | "render_com" | "standard" | "static" | "stormkit" | "vercel" | "vercel-dev" | "vercelDev" | "vercel_dev" | "vercel-static" | "vercelStatic" | "vercel_static" | "winterjs" | "zeabur" | "zeabur-static" | "zeaburStatic" | "zeabur_static" | "zephyr" | "zerops" | "zerops-static" | "zeropsStatic" | "zerops_static" | (string & {}); diff --git a/src/presets/vercel/dev.ts b/src/presets/vercel/dev.ts new file mode 100644 index 0000000000..3707458cbf --- /dev/null +++ b/src/presets/vercel/dev.ts @@ -0,0 +1,44 @@ +import type { Nitro } from "nitro/types"; +import { presetsDir } from "nitro/meta"; +import { resolveModulePath } from "exsolve"; + +/** + * Configure local development emulation for the Vercel preset. + * + * When `vercel.queues.triggers` is configured, propagates the trigger list + * to runtime config and injects a runtime plugin that binds each topic to + * the `vercel:queue` hook through env-runner's queue dev bridge. + * + */ +export async function vercelDevModule(nitro: Nitro) { + if (!nitro.options.dev) { + return; + } + + const triggers = nitro.options.vercel?.queues?.triggers; + if (!triggers?.length) { + return; + } + + if (nitro.options.devServer.runner !== "vercel") { + throw new Error( + `[vercel:queue] Local queue delivery requires the \`vercel\` dev runner, but \`devServer.runner\` is set to "${nitro.options.devServer.runner}". Remove the \`devServer.runner\` override in your \`nitro.config.ts\` or set it explicitly to \`"vercel"\`.` + ); + } + + // Propagate triggers to the runtime plugin via runtimeConfig. + nitro.options.runtimeConfig.vercel = { + ...nitro.options.runtimeConfig.vercel, + queues: { + triggers: triggers.map((t) => ({ ...t })), + }, + }; + + nitro.options.plugins = nitro.options.plugins || []; + nitro.options.plugins.unshift( + resolveModulePath("./vercel/runtime/queue.dev", { + from: presetsDir, + extensions: [".mjs", ".ts"], + }) + ); +} diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index d84067b107..d585115c28 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -9,6 +9,7 @@ import { generateStaticFiles, resolveVercelRuntime, } from "./utils.ts"; +import { vercelDevModule } from "./dev.ts"; import type { VercelFunctionTrigger } from "./types.ts"; @@ -144,4 +145,17 @@ const vercelStatic = defineNitroPreset( } ); -export default [vercel, vercelStatic] as const; +export const vercelDev = defineNitroPreset( + { + extends: "nitro-dev", + devServer: { runner: "vercel" }, + modules: [vercelDevModule], + }, + { + name: "vercel-dev" as const, + aliases: ["vercel"], + dev: true, + } +); + +export default [vercel, vercelStatic, vercelDev] as const; diff --git a/src/presets/vercel/runtime/queue.dev.ts b/src/presets/vercel/runtime/queue.dev.ts new file mode 100644 index 0000000000..310f1a9546 --- /dev/null +++ b/src/presets/vercel/runtime/queue.dev.ts @@ -0,0 +1,57 @@ +import { send } from "@vercel/queue"; +import type { MessageMetadata } from "@vercel/queue"; +import type { NitroAppPlugin } from "nitro/types"; +import { useRuntimeConfig } from "nitro/runtime-config"; +import { registerVercelQueueConsumer } from "env-runner/runners/vercel/queue-dev"; + +interface DevTrigger { + topic: string; + retryAfterSeconds?: number; + initialDelaySeconds?: number; +} + +const queueDevPlugin: NitroAppPlugin = (nitroApp) => { + const triggers = + (useRuntimeConfig() as { vercel?: { queues?: { triggers?: DevTrigger[] } } }).vercel?.queues + ?.triggers || []; + + if (triggers.length === 0) { + return; + } + + const unregisters: Array<() => void> = []; + + for (const trigger of triggers) { + const unregister = registerVercelQueueConsumer({ + topic: trigger.topic, + retryAfterSeconds: trigger.retryAfterSeconds, + handler: async (message: unknown, metadata: unknown) => { + try { + await nitroApp.hooks.callHook("vercel:queue", { + message, + metadata: metadata as MessageMetadata, + send, + }); + } catch (error) { + console.error("[vercel:queue]", error); + nitroApp.captureError?.(error as Error, { + tags: ["vercel:queue"], + }); + // Rethrow so @vercel/queue schedules a local retry. + throw error; + } + }, + }); + unregisters.push(unregister); + } + + nitroApp.hooks.hook("close", () => { + for (const unregister of unregisters) { + try { + unregister(); + } catch {} + } + }); +}; + +export default queueDevPlugin;