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/package.json b/package.json index cb362a22f8..d901918e24 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "consola": "^3.4.2", "crossws": "^0.4.5", "db0": "^0.3.4", - "env-runner": "^0.1.8", + "env-runner": "^0.1.9", "h3": "^2.0.1-rc.22", "hookable": "^6.1.1", "nf3": "^0.3.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21c7df3140..0ea81e1de8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: '*' version: 17.4.2 env-runner: - specifier: ^0.1.8 - version: 0.1.8(@vercel/queue@0.2.0)(miniflare@4.20260520.0) + specifier: ^0.1.9 + version: 0.1.9(@vercel/queue@0.2.0)(miniflare@4.20260520.0) h3: specifier: ^2.0.1-rc.22 version: 2.0.1-rc.22(crossws@0.4.5) @@ -4053,8 +4053,8 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} - env-runner@0.1.8: - resolution: {integrity: sha512-xl/4V//2wJ07QXcwnYxnz/G6+v1BgFc0cj6qNZLA8XtXw3R0kEx+U6jJ/G4glXMhbTAK2zXWhKkcHZ4UYtkCxQ==} + env-runner@0.1.9: + resolution: {integrity: sha512-W9AiZlPx0uXtghAJiTBkeZOgyQdecVvoln3cHoOEZswPq0cVMi+WBhUQjdUn+JcZFAFgOt+i5fcO7C2zniZoCg==} hasBin: true peerDependencies: '@netlify/runtime': ^4.1.23 @@ -10179,7 +10179,7 @@ snapshots: entities@7.0.1: {} - env-runner@0.1.8(@vercel/queue@0.2.0)(miniflare@4.20260520.0): + env-runner@0.1.9(@vercel/queue@0.2.0)(miniflare@4.20260520.0): dependencies: crossws: 0.4.5(srvx@0.11.15) exsolve: 1.0.8 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..39c7ea6a25 --- /dev/null +++ b/src/presets/vercel/runtime/queue.dev.ts @@ -0,0 +1,64 @@ +import { send } from "@vercel/queue"; +import { useRuntimeConfig } from "nitro/runtime-config"; +import { registerVercelQueueConsumer } from "env-runner/runners/vercel/queue-dev"; + +import type { MessageMetadata } from "@vercel/queue"; +import type { NitroAppPlugin } from "nitro/types"; + +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> = []; + + const ready = Promise.all( + triggers.map((trigger) => + 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; + } + }, + }).then((unregister) => { + unregisters.push(unregister); + }) + ) + ).catch((error) => { + console.error("[vercel:queue] failed to register dev consumer:", error); + }); + + nitroApp.hooks.hook("close", async () => { + await ready; + for (const unregister of unregisters) { + try { + unregister(); + } catch {} + } + }); +}; + +export default queueDevPlugin;