Skip to content

Commit 99c41f8

Browse files
authored
feat(vercel): support queues in local dev (#4264)
1 parent 5afb7ca commit 99c41f8

7 files changed

Lines changed: 137 additions & 9 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,12 @@ export default defineEventHandler(async (event) => {
210210
});
211211
```
212212

213+
### Local development
214+
215+
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.
216+
217+
If your hook throws, the message is retried locally. Retries honour `retryAfterSeconds` from each trigger when set.
218+
213219
## Custom build output configuration
214220

215221
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
"consola": "^3.4.2",
7272
"crossws": "^0.4.5",
7373
"db0": "^0.3.4",
74-
"env-runner": "^0.1.8",
74+
"env-runner": "^0.1.9",
7575
"h3": "^2.0.1-rc.22",
7676
"hookable": "^6.1.1",
7777
"nf3": "^0.3.17",

pnpm-lock.yaml

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

src/presets/_types.gen.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ export interface PresetOptions {
2222

2323
export const presetsWithConfig = ["awsAmplify","awsLambda","azure","cloudflare","firebase","netlify","vercel","zephyr"] as const;
2424

25-
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";
25+
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";
2626

27-
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 & {});
27+
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 & {});

src/presets/vercel/dev.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Nitro } from "nitro/types";
2+
import { presetsDir } from "nitro/meta";
3+
import { resolveModulePath } from "exsolve";
4+
5+
/**
6+
* Configure local development emulation for the Vercel preset.
7+
*
8+
* When `vercel.queues.triggers` is configured, propagates the trigger list
9+
* to runtime config and injects a runtime plugin that binds each topic to
10+
* the `vercel:queue` hook through env-runner's queue dev bridge.
11+
*
12+
*/
13+
export async function vercelDevModule(nitro: Nitro) {
14+
if (!nitro.options.dev) {
15+
return;
16+
}
17+
18+
const triggers = nitro.options.vercel?.queues?.triggers;
19+
if (!triggers?.length) {
20+
return;
21+
}
22+
23+
if (nitro.options.devServer.runner !== "vercel") {
24+
throw new Error(
25+
`[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"\`.`
26+
);
27+
}
28+
29+
// Propagate triggers to the runtime plugin via runtimeConfig.
30+
nitro.options.runtimeConfig.vercel = {
31+
...nitro.options.runtimeConfig.vercel,
32+
queues: {
33+
triggers: triggers.map((t) => ({ ...t })),
34+
},
35+
};
36+
37+
nitro.options.plugins = nitro.options.plugins || [];
38+
nitro.options.plugins.unshift(
39+
resolveModulePath("./vercel/runtime/queue.dev", {
40+
from: presetsDir,
41+
extensions: [".mjs", ".ts"],
42+
})
43+
);
44+
}

src/presets/vercel/preset.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
generateStaticFiles,
1010
resolveVercelRuntime,
1111
} from "./utils.ts";
12+
import { vercelDevModule } from "./dev.ts";
1213

1314
import type { VercelFunctionTrigger } from "./types.ts";
1415

@@ -144,4 +145,17 @@ const vercelStatic = defineNitroPreset(
144145
}
145146
);
146147

147-
export default [vercel, vercelStatic] as const;
148+
export const vercelDev = defineNitroPreset(
149+
{
150+
extends: "nitro-dev",
151+
devServer: { runner: "vercel" },
152+
modules: [vercelDevModule],
153+
},
154+
{
155+
name: "vercel-dev" as const,
156+
aliases: ["vercel"],
157+
dev: true,
158+
}
159+
);
160+
161+
export default [vercel, vercelStatic, vercelDev] as const;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { send } from "@vercel/queue";
2+
import { useRuntimeConfig } from "nitro/runtime-config";
3+
import { registerVercelQueueConsumer } from "env-runner/runners/vercel/queue-dev";
4+
5+
import type { MessageMetadata } from "@vercel/queue";
6+
import type { NitroAppPlugin } from "nitro/types";
7+
8+
interface DevTrigger {
9+
topic: string;
10+
retryAfterSeconds?: number;
11+
initialDelaySeconds?: number;
12+
}
13+
14+
const queueDevPlugin: NitroAppPlugin = (nitroApp) => {
15+
const triggers =
16+
(useRuntimeConfig() as { vercel?: { queues?: { triggers?: DevTrigger[] } } }).vercel?.queues
17+
?.triggers || [];
18+
19+
if (triggers.length === 0) {
20+
return;
21+
}
22+
23+
const unregisters: Array<() => void> = [];
24+
25+
const ready = Promise.all(
26+
triggers.map((trigger) =>
27+
registerVercelQueueConsumer({
28+
topic: trigger.topic,
29+
retryAfterSeconds: trigger.retryAfterSeconds,
30+
handler: async (message: unknown, metadata: unknown) => {
31+
try {
32+
await nitroApp.hooks.callHook("vercel:queue", {
33+
message,
34+
metadata: metadata as MessageMetadata,
35+
send,
36+
});
37+
} catch (error) {
38+
console.error("[vercel:queue]", error);
39+
nitroApp.captureError?.(error as Error, {
40+
tags: ["vercel:queue"],
41+
});
42+
// Rethrow so @vercel/queue schedules a local retry.
43+
throw error;
44+
}
45+
},
46+
}).then((unregister) => {
47+
unregisters.push(unregister);
48+
})
49+
)
50+
).catch((error) => {
51+
console.error("[vercel:queue] failed to register dev consumer:", error);
52+
});
53+
54+
nitroApp.hooks.hook("close", async () => {
55+
await ready;
56+
for (const unregister of unregisters) {
57+
try {
58+
unregister();
59+
} catch {}
60+
}
61+
});
62+
};
63+
64+
export default queueDevPlugin;

0 commit comments

Comments
 (0)