Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/2.deploy/20.providers/vercel.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export default defineNitroConfig({
```

::note
The [`@vercel/queue`](https://www.npmjs.com/package/@vercel/queue) package is required when using queues. Install it in your project with your package manager.
The [`@vercel/queue`](https://www.npmjs.com/package/@vercel/queue) package is required when using queues. Install it in your project with your package manager. Local development requires `@vercel/queue` v0.2.0 or newer.
::

### Handling messages
Expand Down Expand Up @@ -135,6 +135,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.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@
"@types/semver": "^7.7.1",
"@types/serve-static": "^2.2.0",
"@types/xml2js": "^0.4.14",
"@vercel/queue": "^0.1.4",
"@vercel/queue": "^0.2.0",
"@vitest/coverage-v8": "^4.1.5",
"automd": "^0.4.3",
"changelogen": "^0.6.2",
Expand All @@ -208,7 +208,7 @@
"xml2js": "^0.6.2"
},
"peerDependencies": {
"@vercel/queue": "^0.1.4",
"@vercel/queue": "^0.2.0",
"xml2js": "^0.6.2"
},
"peerDependenciesMeta": {
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/presets/_types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ export interface PresetOptions {

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

export type PresetName = "alwaysdata" | "aws-amplify" | "aws-lambda" | "azure" | "azure-functions" | "azure-swa" | "base-worker" | "bun" | "cleavr" | "cli" | "cloudflare" | "cloudflare-dev" | "cloudflare-durable" | "cloudflare-module" | "cloudflare-module-legacy" | "cloudflare-pages" | "cloudflare-pages-static" | "cloudflare-worker" | "deno" | "deno-deploy" | "deno-server" | "deno-server-legacy" | "digital-ocean" | "edgio" | "firebase" | "firebase-app-hosting" | "flight-control" | "genezio" | "github-pages" | "gitlab-pages" | "heroku" | "iis" | "iis-handler" | "iis-node" | "koyeb" | "layer0" | "netlify" | "netlify-builder" | "netlify-edge" | "netlify-legacy" | "netlify-static" | "nitro-dev" | "nitro-prerender" | "node" | "node-cluster" | "node-listener" | "node-server" | "platform-sh" | "render-com" | "service-worker" | "static" | "stormkit" | "vercel" | "vercel-edge" | "vercel-static" | "winterjs" | "zeabur" | "zeabur-static" | "zerops" | "zerops-static";
export type PresetName = "alwaysdata" | "aws-amplify" | "aws-lambda" | "azure" | "azure-functions" | "azure-swa" | "base-worker" | "bun" | "cleavr" | "cli" | "cloudflare" | "cloudflare-dev" | "cloudflare-durable" | "cloudflare-module" | "cloudflare-module-legacy" | "cloudflare-pages" | "cloudflare-pages-static" | "cloudflare-worker" | "deno" | "deno-deploy" | "deno-server" | "deno-server-legacy" | "digital-ocean" | "edgio" | "firebase" | "firebase-app-hosting" | "flight-control" | "genezio" | "github-pages" | "gitlab-pages" | "heroku" | "iis" | "iis-handler" | "iis-node" | "koyeb" | "layer0" | "netlify" | "netlify-builder" | "netlify-edge" | "netlify-legacy" | "netlify-static" | "nitro-dev" | "nitro-prerender" | "node" | "node-cluster" | "node-listener" | "node-server" | "platform-sh" | "render-com" | "service-worker" | "static" | "stormkit" | "vercel" | "vercel-dev" | "vercel-edge" | "vercel-static" | "winterjs" | "zeabur" | "zeabur-static" | "zerops" | "zerops-static";

export type PresetNameInput = "alwaysdata" | "aws-amplify" | "awsAmplify" | "aws_amplify" | "aws-lambda" | "awsLambda" | "aws_lambda" | "azure" | "azure-functions" | "azureFunctions" | "azure_functions" | "azure-swa" | "azureSwa" | "azure_swa" | "base-worker" | "baseWorker" | "base_worker" | "bun" | "cleavr" | "cli" | "cloudflare" | "cloudflare-dev" | "cloudflareDev" | "cloudflare_dev" | "cloudflare-durable" | "cloudflareDurable" | "cloudflare_durable" | "cloudflare-module" | "cloudflareModule" | "cloudflare_module" | "cloudflare-module-legacy" | "cloudflareModuleLegacy" | "cloudflare_module_legacy" | "cloudflare-pages" | "cloudflarePages" | "cloudflare_pages" | "cloudflare-pages-static" | "cloudflarePagesStatic" | "cloudflare_pages_static" | "cloudflare-worker" | "cloudflareWorker" | "cloudflare_worker" | "deno" | "deno-deploy" | "denoDeploy" | "deno_deploy" | "deno-server" | "denoServer" | "deno_server" | "deno-server-legacy" | "denoServerLegacy" | "deno_server_legacy" | "digital-ocean" | "digitalOcean" | "digital_ocean" | "edgio" | "firebase" | "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" | "iis-handler" | "iisHandler" | "iis_handler" | "iis-node" | "iisNode" | "iis_node" | "koyeb" | "layer0" | "netlify" | "netlify-builder" | "netlifyBuilder" | "netlify_builder" | "netlify-edge" | "netlifyEdge" | "netlify_edge" | "netlify-legacy" | "netlifyLegacy" | "netlify_legacy" | "netlify-static" | "netlifyStatic" | "netlify_static" | "nitro-dev" | "nitroDev" | "nitro_dev" | "nitro-prerender" | "nitroPrerender" | "nitro_prerender" | "node" | "node-cluster" | "nodeCluster" | "node_cluster" | "node-listener" | "nodeListener" | "node_listener" | "node-server" | "nodeServer" | "node_server" | "platform-sh" | "platformSh" | "platform_sh" | "render-com" | "renderCom" | "render_com" | "service-worker" | "serviceWorker" | "service_worker" | "static" | "stormkit" | "vercel" | "vercel-edge" | "vercelEdge" | "vercel_edge" | "vercel-static" | "vercelStatic" | "vercel_static" | "winterjs" | "zeabur" | "zeabur-static" | "zeaburStatic" | "zeabur_static" | "zerops" | "zerops-static" | "zeropsStatic" | "zerops_static" | (string & {});
export type PresetNameInput = "alwaysdata" | "aws-amplify" | "awsAmplify" | "aws_amplify" | "aws-lambda" | "awsLambda" | "aws_lambda" | "azure" | "azure-functions" | "azureFunctions" | "azure_functions" | "azure-swa" | "azureSwa" | "azure_swa" | "base-worker" | "baseWorker" | "base_worker" | "bun" | "cleavr" | "cli" | "cloudflare" | "cloudflare-dev" | "cloudflareDev" | "cloudflare_dev" | "cloudflare-durable" | "cloudflareDurable" | "cloudflare_durable" | "cloudflare-module" | "cloudflareModule" | "cloudflare_module" | "cloudflare-module-legacy" | "cloudflareModuleLegacy" | "cloudflare_module_legacy" | "cloudflare-pages" | "cloudflarePages" | "cloudflare_pages" | "cloudflare-pages-static" | "cloudflarePagesStatic" | "cloudflare_pages_static" | "cloudflare-worker" | "cloudflareWorker" | "cloudflare_worker" | "deno" | "deno-deploy" | "denoDeploy" | "deno_deploy" | "deno-server" | "denoServer" | "deno_server" | "deno-server-legacy" | "denoServerLegacy" | "deno_server_legacy" | "digital-ocean" | "digitalOcean" | "digital_ocean" | "edgio" | "firebase" | "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" | "iis-handler" | "iisHandler" | "iis_handler" | "iis-node" | "iisNode" | "iis_node" | "koyeb" | "layer0" | "netlify" | "netlify-builder" | "netlifyBuilder" | "netlify_builder" | "netlify-edge" | "netlifyEdge" | "netlify_edge" | "netlify-legacy" | "netlifyLegacy" | "netlify_legacy" | "netlify-static" | "netlifyStatic" | "netlify_static" | "nitro-dev" | "nitroDev" | "nitro_dev" | "nitro-prerender" | "nitroPrerender" | "nitro_prerender" | "node" | "node-cluster" | "nodeCluster" | "node_cluster" | "node-listener" | "nodeListener" | "node_listener" | "node-server" | "nodeServer" | "node_server" | "platform-sh" | "platformSh" | "platform_sh" | "render-com" | "renderCom" | "render_com" | "service-worker" | "serviceWorker" | "service_worker" | "static" | "stormkit" | "vercel" | "vercel-dev" | "vercelDev" | "vercel_dev" | "vercel-edge" | "vercelEdge" | "vercel_edge" | "vercel-static" | "vercelStatic" | "vercel_static" | "winterjs" | "zeabur" | "zeabur-static" | "zeaburStatic" | "zeabur_static" | "zerops" | "zerops-static" | "zeropsStatic" | "zerops_static" | (string & {});
54 changes: 54 additions & 0 deletions src/presets/vercel/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { fileURLToPath } from "node:url";
import { resolveModulePath } from "exsolve";
import type { Nitro } from "nitropack/types";

/**
* 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 `@vercel/queue`'s local dev consumer registry.
*/
export async function vercelDev(nitro: Nitro) {
if (!nitro.options.dev) {
return; // Production doesn't need this
}

const triggers = nitro.options.vercel?.queues?.triggers;
if (!triggers?.length) {
return;
}

// `@vercel/queue` is an optional peer dependency. Without it, local queue
// delivery is simply disabled (dev startup is never blocked).
const resolved = resolveModulePath("@vercel/queue", {
from: nitro.options.nodeModulesDirs,
try: true,
});
if (!resolved) {
nitro.logger.warn(
"`@vercel/queue` is not installed. Local queue delivery is disabled. Install it with `npx nypm i @vercel/queue` to enable it."
);
return;
}

// Propagate triggers to the runtime plugin via runtimeConfig.
nitro.options.runtimeConfig.vercel = {
...nitro.options.runtimeConfig.vercel,
queues: {
triggers: triggers.map((t) => ({ ...t })),
},
};

// Make sure the runtime plugin is transpiled.
nitro.options.externals.inline = nitro.options.externals.inline || [];
nitro.options.externals.inline.push(
fileURLToPath(new URL("runtime/", import.meta.url))
);

// Inject the dev consumer plugin.
nitro.options.plugins = nitro.options.plugins || [];
nitro.options.plugins.unshift(
fileURLToPath(new URL("runtime/queue.dev", import.meta.url))
);
}
17 changes: 16 additions & 1 deletion src/presets/vercel/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,19 @@ const vercelStatic = defineNitroPreset(
}
);

export default [vercel, vercelEdge, vercelStatic] as const;
export const vercelDev = defineNitroPreset(
{
extends: "nitro-dev",
modules: [
async (nitro) => await import("./dev").then((m) => m.vercelDev(nitro)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor | ⚑ Quick win

Use an explicit extension in the dynamic import.

Line 156 uses import("./dev"). Please switch to an explicit extension (.ts/.mjs) for consistency with the repository ESM import rule.

Suggested patch
-      async (nitro) => await import("./dev").then((m) => m.vercelDev(nitro)),
+      async (nitro) => await import("./dev.ts").then((m) => m.vercelDev(nitro)),

As per coding guidelines, src/**/*.{ts,js} imports should use explicit .ts/.mjs extensions.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/presets/vercel/preset.ts` at line 156, Update the dynamic import used in
the async loader expression (the arrow function async (nitro) => await
import("./dev").then((m) => m.vercelDev(nitro))) to use an explicit module
extension; replace import("./dev") with an explicit import path such as
import("./dev.ts") (or .mjs if that matches your build) so the ESM import rule
for src/**/*.{ts,js} is respected.

Source: Coding guidelines

],
},
{
name: "vercel-dev" as const,
aliases: ["vercel"],
dev: true,
url: import.meta.url,
}
);

export default [vercel, vercelEdge, vercelStatic, vercelDev] as const;
124 changes: 124 additions & 0 deletions src/presets/vercel/runtime/queue.dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { defineNitroPlugin, useRuntimeConfig } from "nitropack/runtime";

import type { MessageHandler, QueueClient } from "@vercel/queue";

// `@vercel/queue` gates local `send()` routing behind `isDevMode()`, which
// checks `process.env.NODE_ENV === "development"`. This plugin is only ever
// loaded by the dev-only `vercel-dev` preset, so defaulting it here is safe
// and never affects production builds. Bracket access avoids the dev
// bundler's static `process.env.NODE_ENV` replacement (which would otherwise
// rewrite this into an assignment to a string literal).
process.env["NODE_ENV"] ??= "development";

const CONSUMER_GROUP = "nitro-vercel-dev";

interface DevTrigger {
topic: string;
retryAfterSeconds?: number;
}

type VercelQueueSdk = typeof import("@vercel/queue");

let sdkPromise: Promise<VercelQueueSdk | null> | undefined;
let client: QueueClient | undefined;

/**
* Lazily load `@vercel/queue` and construct a shared `QueueClient`.
*
* Resolves to `null` (and logs a one-time warning) when the package is not
* installed or is too old to expose `registerDevConsumer` (added in
* `@vercel/queue@^0.2.0`), so registrations become no-ops.
*/
function ensureSdk(): Promise<VercelQueueSdk | null> {
if (sdkPromise) {
return sdkPromise;
}
sdkPromise = (async () => {
let mod: VercelQueueSdk;
try {
mod = await import("@vercel/queue");
} catch {
console.warn(
"[vercel:queue] `@vercel/queue` is not installed. Local queue delivery is disabled."
);
return null;
}
if (typeof mod.registerDevConsumer !== "function") {
console.warn(
"[vercel:queue] Installed `@vercel/queue` does not export `registerDevConsumer`. Upgrade to `@vercel/queue@^0.2.0` to enable local queue delivery."
);
return null;
}
client = new mod.QueueClient();
return mod;
})();
return sdkPromise;
}

export default defineNitroPlugin((nitroApp) => {
const triggers =
(
useRuntimeConfig() as {
vercel?: { queues?: { triggers?: DevTrigger[] } };
}
).vercel?.queues?.triggers || [];

if (triggers.length === 0) {
return;
}

const unregisters: Array<() => void> = [];

const ready = (async () => {
const sdk = await ensureSdk();
if (!sdk || !client) {
return;
}
for (const trigger of triggers) {
const handler: MessageHandler = async (message, metadata) => {
try {
await nitroApp.hooks.callHook("vercel:queue", {
message,
metadata,
send: sdk.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;
}
};

// Compare against `undefined` so a configured `0` is honored.
const { retryAfterSeconds } = trigger;
unregisters.push(
sdk.registerDevConsumer({
topic: trigger.topic,
client,
consumerGroup: CONSUMER_GROUP,
retry:
retryAfterSeconds === undefined
? undefined
: () => ({ afterSeconds: retryAfterSeconds }),
handler,
})
);
}
})().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 {
// ignore unregister failures during shutdown
}
}
});
});