From a18bbd35414ae6ae0f202b941dc1d9ab6a423a01 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 3 Jun 2026 14:04:26 +0100 Subject: [PATCH 1/2] preset(vercel): support vercel queues --- docs/2.deploy/20.providers/vercel.md | 66 +++++++ package.json | 5 + pnpm-lock.yaml | 65 +++++++ src/presets/vercel/preset.ts | 25 +++ src/presets/vercel/runtime/queue-handler.ts | 24 +++ src/presets/vercel/types.ts | 67 +++++++ src/presets/vercel/utils.ts | 104 ++++++++++- test/unit/vercel.queues.test.ts | 188 ++++++++++++++++++++ 8 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 src/presets/vercel/runtime/queue-handler.ts create mode 100644 test/unit/vercel.queues.test.ts diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index 5c6da90108..bb2a72ef01 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -69,6 +69,72 @@ Alternatively, Nitro also detects Bun automatically if you specify a `bunVersion } ``` +## 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: [ + // Only `topic` is required + { topic: "notifications" }, + { topic: "orders", retryAfterSeconds: 60, initialDelaySeconds: 5 }, + ], + }, + }, +}); +``` + +::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. +:: + +### 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, send }) => { + console.log(`[${metadata.topicName}] Message ${metadata.messageId}:`, message); + }); +}); +``` + +### Running tasks from queue messages + +You can use queue messages to trigger [Nitro tasks](/guide/tasks): + +```ts [server/plugins/queues.ts] +import { runTask } from "nitropack/runtime"; + +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 readBody(event); + 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 50df7bceef..fb05af0f01 100644 --- a/package.json +++ b/package.json @@ -185,6 +185,7 @@ "@types/semver": "^7.7.1", "@types/serve-static": "^2.2.0", "@types/xml2js": "^0.4.14", + "@vercel/queue": "^0.1.4", "@vitest/coverage-v8": "^4.1.2", "automd": "^0.4.3", "changelogen": "^0.6.2", @@ -207,9 +208,13 @@ "xml2js": "^0.6.2" }, "peerDependencies": { + "@vercel/queue": "^0.1.4", "xml2js": "^0.6.2" }, "peerDependenciesMeta": { + "@vercel/queue": { + "optional": true + }, "xml2js": { "optional": true } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6a18e0619..b506661d97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,6 +270,9 @@ importers: '@types/xml2js': specifier: ^0.4.14 version: 0.4.14 + '@vercel/queue': + specifier: ^0.1.4 + version: 0.1.7 '@vitest/coverage-v8': specifier: ^4.1.2 version: 4.1.2(vitest@4.1.2(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3))) @@ -1324,89 +1327,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1646,36 +1665,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-wasm@2.5.6': resolution: {integrity: sha512-byAiBZ1t3tXQvc8dMD/eoyE7lTXYorhn+6uVW5AC+JGI1KtJC/LvDche5cfUE+qiefH+Ybq0bUCJU0aB1cSHUA==} @@ -1814,36 +1839,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} @@ -1995,66 +2026,79 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -2485,6 +2529,10 @@ packages: resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} + '@vercel/queue@0.1.7': + resolution: {integrity: sha512-4Uk9LOvDPVYqBJGrNDk4fdLte4CmFERXCUJI2y7SRYX1d//dI96Ww7sgKZF4+YDj/YaBXoCZ11AU0HYkVwW9Eg==} + engines: {node: '>=20.0.0'} + '@vitest/coverage-v8@4.1.2': resolution: {integrity: sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==} peerDependencies: @@ -4505,24 +4553,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -4896,6 +4948,10 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mixpart@0.0.6: + resolution: {integrity: sha512-CRdXtgfQH2jARmtNmPR0Q7jL20fiESbaYk1b0KvLD0jCdUuemepREtsbd8nbiY6BHV9OGGddAZITNXklupUPUQ==} + engines: {node: '>=20.0.0'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -8907,6 +8963,13 @@ snapshots: '@vercel/oidc@3.1.0': {} + '@vercel/queue@0.1.7': + dependencies: + '@vercel/oidc': 3.1.0 + minimatch: 10.2.5 + mixpart: 0.0.6 + picocolors: 1.1.1 + '@vitest/coverage-v8@4.1.2(vitest@4.1.2(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3)))': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -11754,6 +11817,8 @@ snapshots: dependencies: minipass: 7.1.3 + mixpart@0.0.6: {} + mkdirp-classic@0.5.3: {} mkdirp@0.5.6: diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index 76e5e0825f..6b2fb2a6b4 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -1,6 +1,9 @@ +import { fileURLToPath } from "node:url"; +import { resolveModulePath } from "exsolve"; import { defineNitroPreset } from "nitropack/kit"; import type { Nitro } from "nitropack/types"; import { + DEFAULT_QUEUE_HANDLER_ROUTE, deprecateSWR, generateEdgeFunctionFiles, generateFunctionFiles, @@ -29,6 +32,28 @@ const vercel = defineNitroPreset( deploy: "npx vercel deploy --prebuilt", }, hooks: { + "build:before": (nitro: Nitro) => { + // Queue consumer handler + const queues = nitro.options.vercel?.queues; + if (queues?.triggers?.length) { + const resolved = resolveModulePath("@vercel/queue", { + from: [nitro.options.rootDir, import.meta.url], + try: true, + }); + if (!resolved) { + throw new Error( + "`@vercel/queue` is required for Vercel Queues. Please add it to your dependencies." + ); + } + nitro.options.handlers.push({ + route: queues.handlerRoute || DEFAULT_QUEUE_HANDLER_ROUTE, + lazy: true, + handler: fileURLToPath( + new URL("runtime/queue-handler", import.meta.url) + ), + }); + } + }, "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..a29b465fcd --- /dev/null +++ b/src/presets/vercel/runtime/queue-handler.ts @@ -0,0 +1,24 @@ +import { handleCallback, send } from "@vercel/queue"; +import { defineEventHandler, toWebRequest } from "h3"; +import { useNitroApp } from "nitropack/runtime"; + +export default defineEventHandler((event) => { + const nitroApp = useNitroApp(); + return handleCallback(async (message, metadata) => { + try { + await nitroApp.hooks.callHook("vercel:queue", { + message, + metadata, + send, + }); + } catch (error) { + console.error("[vercel:queue]", error); + nitroApp.captureError?.(error as Error, { + event, + tags: ["vercel:queue"], + }); + // Rethrow so @vercel/queue schedules a retry. + throw error; + } + })(toWebRequest(event)); +}); diff --git a/src/presets/vercel/types.ts b/src/presets/vercel/types.ts index 817f0c52e0..433c2aa249 100644 --- a/src/presets/vercel/types.ts +++ b/src/presets/vercel/types.ts @@ -1,3 +1,5 @@ +import type { send } from "@vercel/queue"; + /** * Vercel Build Output Configuration * @see https://vercel.com/docs/build-output-api/v3 @@ -106,9 +108,22 @@ export interface VercelServerlessFunctionConfig { */ runtime?: "nodejs20.x" | "nodejs22.x" | "bun1.x" | (string & {}); + /** + * Experimental trigger configuration (e.g., Vercel Queues). + */ + experimentalTriggers?: VercelFunctionTrigger[]; + [key: string]: unknown; } +export type VercelFunctionTrigger = { + type: "queue/v2beta"; + topic: string; + retryAfterSeconds?: number; + initialDelaySeconds?: number; + consumer?: string; +}; + export interface VercelOptions { config: VercelBuildConfigV3; @@ -127,6 +142,48 @@ export interface VercelOptions { regions?: string[]; functions?: VercelServerlessFunctionConfig; + + /** + * 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; + retryAfterSeconds?: number; + initialDelaySeconds?: number; + }>; + }; } /** @@ -168,3 +225,13 @@ export type PrerenderFunctionConfig = { */ exposeErrBody?: boolean; }; + +declare module "nitropack/types" { + export interface NitroRuntimeHooks { + "vercel:queue": (_: { + message: unknown; + metadata: import("@vercel/queue").MessageMetadata; + send: typeof send; + }) => void; + } +} diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index e382f6fbb8..70d264c617 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -20,6 +20,8 @@ const SUPPORTED_NODE_VERSIONS = [18, 20, 22]; const FALLBACK_ROUTE = "/__fallback"; +export const DEFAULT_QUEUE_HANDLER_ROUTE = "/_vercel/queues/consumer"; + const ISR_SUFFIX = "-isr"; // Avoid using . as it can conflict with routing const SAFE_FS_CHAR_RE = /[^a-zA-Z0-9_.[\]/]/g; @@ -74,6 +76,40 @@ export async function generateFunctionFiles(nitro: Nitro) { }; await writeFile(functionConfigPath, JSON.stringify(functionConfig, null, 2)); + // Write queue consumer function (Vercel Queues) + const queues = nitro.options.vercel?.queues; + if (queues?.triggers?.length) { + const handlerRoute = queues.handlerRoute || DEFAULT_QUEUE_HANDLER_ROUTE; + const funcDest = normalizeRouteDest(handlerRoute); + const funcDir = resolve( + nitro.options.output.serverDir, + "..", + funcDest + ".func" + ); + + // The Vercel preset symlinks every route to the fallback function, but a + // queue consumer needs its own `.vc-config.json` with `experimentalTriggers`. + // Replace any existing entry with a real copy of the server bundle so the + // triggers can be attached to it. (Symlinks pointing outside the `.func` + // directory also break at runtime on Vercel.) + await fsp.rm(funcDir, { recursive: true, force: true }); + await fsp.mkdir(dirname(funcDir), { recursive: true }); + await fsp.cp(nitro.options.output.serverDir, funcDir, { recursive: true }); + + const consumer = sanitizeConsumerName(funcDest); + const experimentalTriggers = queues.triggers.map(({ topic, ...opts }) => ({ + type: "queue/v2beta" as const, + topic, + ...opts, + consumer, + })); + + await writeFile( + resolve(funcDir, ".vc-config.json"), + JSON.stringify({ ...functionConfig, experimentalTriggers }, null, 2) + ); + } + // Write ISR functions for (const [key, value] of Object.entries(nitro.options.routeRules)) { if (!value.isr) { @@ -126,7 +162,16 @@ export async function generateFunctionFiles(nitro: Nitro) { } } +function warnUnsupportedQueues(nitro: Nitro) { + if (nitro.options.vercel?.queues?.triggers?.length) { + nitro.logger.warn( + `\`vercel.queues\` is only supported by the \`vercel\` preset and is ignored for \`${nitro.options.preset}\`.` + ); + } +} + export async function generateEdgeFunctionFiles(nitro: Nitro) { + warnUnsupportedQueues(nitro); const buildConfigPath = resolve(nitro.options.output.dir, "config.json"); const buildConfig = generateBuildConfig(nitro); await writeFile(buildConfigPath, JSON.stringify(buildConfig, null, 2)); @@ -144,6 +189,7 @@ export async function generateEdgeFunctionFiles(nitro: Nitro) { } export async function generateStaticFiles(nitro: Nitro) { + warnUnsupportedQueues(nitro); const buildConfigPath = resolve(nitro.options.output.dir, "config.json"); const buildConfig = generateBuildConfig(nitro); await writeFile(buildConfigPath, JSON.stringify(buildConfig, null, 2)); @@ -154,6 +200,10 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { (a, b) => b[0].split(/\/(?!\*)/).length - a[0].split(/\/(?!\*)/).length ); + const queueHandlerRoute = nitro.options.vercel?.queues?.triggers?.length + ? nitro.options.vercel.queues.handlerRoute || DEFAULT_QUEUE_HANDLER_ROUTE + : undefined; + const config = defu(nitro.options.vercel?.config, { version: 3, overrides: { @@ -229,6 +279,20 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { } config.routes!.push( + // Queue consumer route (Vercel Queues) + // ...Emitted before ISR/observability routes so a catch-all (`/**`) rule + // cannot shadow the dedicated consumer function. + ...(queueHandlerRoute + ? [ + { + src: joinURL( + nitro.options.baseURL, + normalizeRouteSrc(queueHandlerRoute) + ), + dest: withLeadingSlash(normalizeRouteDest(queueHandlerRoute)), + }, + ] + : []), // ISR rules // ...If we are using an ISR function for /, then we need to write this explicitly ...(nitro.options.routeRules["/"]?.isr @@ -347,6 +411,12 @@ function getObservabilityRoutes(nitro: Nitro): ObservabilityRoute[] { return []; } + // The queue consumer gets its own (non-symlinked) function directory with + // `experimentalTriggers`, so exclude it from the observability symlinks. + const queueHandlerRoute = nitro.options.vercel?.queues?.triggers?.length + ? nitro.options.vercel.queues.handlerRoute || DEFAULT_QUEUE_HANDLER_ROUTE + : undefined; + // Sort routes by how much specific they are const routePatterns = [ ...new Set([ @@ -355,7 +425,7 @@ function getObservabilityRoutes(nitro: Nitro): ObservabilityRoute[] { .filter((h) => !h.middleware && h.route) .map((h) => h.route!), ]), - ]; + ].filter((route) => route !== queueHandlerRoute); const staticRoutes: string[] = []; const dynamicRoutes: string[] = []; @@ -454,6 +524,38 @@ function normalizeRouteDest(route: string) { ); } +/** + * Encodes a function path into a consumer name for queue/v2beta triggers. + * Mirrors the encoding from @vercel/build-utils sanitizeConsumerName(). + * @see https://github.com/vercel/vercel/blob/main/packages/build-utils/src/lambda.ts + */ +function sanitizeConsumerName(functionPath: string): string { + let result = ""; + for (const char of functionPath) { + switch (char) { + case "_": { + result += "__"; + break; + } + case "/": { + result += "_S"; + break; + } + case ".": { + result += "_D"; + break; + } + default: { + result += /[A-Za-z0-9-]/.test(char) + ? char + : "_" + + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0"); + } + } + } + return result; +} + async function writePrerenderConfig( filename: string, isrConfig: NitroRouteRules["isr"], diff --git a/test/unit/vercel.queues.test.ts b/test/unit/vercel.queues.test.ts new file mode 100644 index 0000000000..033980d7ee --- /dev/null +++ b/test/unit/vercel.queues.test.ts @@ -0,0 +1,188 @@ +import { promises as fsp } from "node:fs"; +import { tmpdir } from "node:os"; +import type { Nitro } from "nitropack/types"; +import { join, resolve } from "pathe"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { generateFunctionFiles } from "../../src/presets/vercel/utils"; + +// These tests exercise the build output directly via `generateFunctionFiles` +// with a minimal Nitro stub, avoiding a full (rollup) build via `setupTest`. +describe("vercel queues build output", () => { + let outDir: string; + let serverDir: string; + + // A default older compat date keeps observability routes disabled (so most + // assertions don't need handlers/ssrRoutes); override via `options`. + const createNitroStub = ( + vercel: unknown, + options: Record = {} + ): Nitro => + ({ + options: { + rootDir: outDir, + baseURL: "/", + static: false, + routeRules: {}, + publicAssets: [], + ssrRoutes: [], + handlers: [], + compatibilityDate: { default: "2025-01-01" }, + output: { dir: outDir, serverDir }, + vercel, + ...options, + }, + scannedHandlers: [], + _prerenderedRoutes: [], + }) as unknown as Nitro; + + beforeAll(async () => { + outDir = await fsp.mkdtemp(join(tmpdir(), "nitro-vercel-queues-")); + serverDir = resolve(outDir, "functions/__fallback.func"); + await fsp.mkdir(serverDir, { recursive: true }); + // The queue consumer is a copy of the fallback server bundle. + await fsp.writeFile( + resolve(serverDir, "index.mjs"), + "export default {};\n" + ); + }); + + afterAll(async () => { + await fsp.rm(outDir, { recursive: true, force: true }); + }); + + it("creates a real consumer function dir with experimentalTriggers", async () => { + await generateFunctionFiles( + createNitroStub({ + queues: { + triggers: [ + { topic: "orders", retryAfterSeconds: 60 }, + { topic: "notifications", initialDelaySeconds: 10 }, + ], + }, + }) + ); + + const funcDir = resolve(outDir, "functions/_vercel/queues/consumer.func"); + + // Must be a real directory, not a symlink to the fallback function. + const stat = await fsp.lstat(funcDir); + expect(stat.isDirectory()).toBe(true); + expect(stat.isSymbolicLink()).toBe(false); + + // The fallback bundle is copied into the consumer function. + const index = await fsp.readFile(resolve(funcDir, "index.mjs"), "utf8"); + expect(index).toContain("export default"); + + 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", + retryAfterSeconds: 60, + consumer: "__vercel_Squeues_Sconsumer", + }, + { + type: "queue/v2beta", + topic: "notifications", + initialDelaySeconds: 10, + consumer: "__vercel_Squeues_Sconsumer", + }, + ]); + expect(config.handler).toBe("index.mjs"); + + // The triggers must only live on the copied consumer function. If it were a + // symlink to the fallback, this write would have mutated the shared config. + const fallbackConfig = await fsp + .readFile(resolve(serverDir, ".vc-config.json"), "utf8") + .then((r) => JSON.parse(r)); + expect(fallbackConfig.experimentalTriggers).toBeUndefined(); + }); + + it("adds the queue consumer route to config.json", async () => { + await generateFunctionFiles( + createNitroStub({ queues: { triggers: [{ topic: "orders" }] } }) + ); + + const config = await fsp + .readFile(resolve(outDir, "config.json"), "utf8") + .then((r) => JSON.parse(r)); + const routes = config.routes as { src: string; dest: string }[]; + expect( + routes.find( + (r) => + r.src === "/_vercel/queues/consumer" && + r.dest === "/_vercel/queues/consumer" + ) + ).toBeDefined(); + }); + + it("honors a custom handlerRoute", async () => { + await generateFunctionFiles( + createNitroStub({ + queues: { + handlerRoute: "/api/_queue", + triggers: [{ topic: "orders" }], + }, + }) + ); + + const funcDir = resolve(outDir, "functions/api/_queue.func"); + const config = await fsp + .readFile(resolve(funcDir, ".vc-config.json"), "utf8") + .then((r) => JSON.parse(r)); + expect(config.experimentalTriggers[0].consumer).toBe("api_S__queue"); + + const buildConfig = await fsp + .readFile(resolve(outDir, "config.json"), "utf8") + .then((r) => JSON.parse(r)); + const routes = buildConfig.routes as { src: string; dest: string }[]; + expect( + routes.find((r) => r.src === "/api/_queue" && r.dest === "/api/_queue") + ).toBeDefined(); + }); + + it("does not create a consumer function without triggers", async () => { + await generateFunctionFiles(createNitroStub({})); + + const buildConfig = await fsp + .readFile(resolve(outDir, "config.json"), "utf8") + .then((r) => JSON.parse(r)); + const routes = buildConfig.routes as { src?: string }[]; + expect(routes.some((r) => r.src?.includes("/_vercel/queues/"))).toBe(false); + }); + + // With a modern compat date, a catch-all (`/**`) handler produces an + // observability route `/(?:.*)` that matches everything. The queue route must + // be emitted before it (and only once) or queue callbacks get routed to the + // fallback function, which lacks `experimentalTriggers`. + it("emits the queue route before the observability catch-all (and only once)", async () => { + await generateFunctionFiles( + createNitroStub( + { queues: { triggers: [{ topic: "orders" }] } }, + { + compatibilityDate: { default: "2025-08-01" }, + ssrRoutes: ["/**"], + // The preset registers the consumer as a real handler; it must be + // excluded from observability so it isn't added/symlinked twice. + handlers: [{ route: "/_vercel/queues/consumer" }], + } + ) + ); + + const config = await fsp + .readFile(resolve(outDir, "config.json"), "utf8") + .then((r) => JSON.parse(r)); + const routes = config.routes as { src?: string; dest?: string }[]; + + const queueIdxs = routes + .map((r, i) => (r.dest === "/_vercel/queues/consumer" ? i : -1)) + .filter((i) => i !== -1); + expect(queueIdxs).toHaveLength(1); + + const catchAllIdx = routes.findIndex((r) => r.src === "/(?:.*)"); + expect(catchAllIdx).toBeGreaterThan(-1); + expect(queueIdxs[0]).toBeLessThan(catchAllIdx); + }); +}); From e88de7abca7511911dc6f22c1d56f320e8df541d Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Tue, 9 Jun 2026 15:26:40 +0100 Subject: [PATCH 2/2] fix: prevent setting queue route to /__fallback --- src/presets/vercel/utils.ts | 12 +++++++++++- test/unit/vercel.queues.test.ts | 29 ++++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index 70d264c617..51ba7b7f8c 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -2,7 +2,7 @@ import fsp from "node:fs/promises"; import { defu } from "defu"; import { writeFile } from "nitropack/kit"; import type { Nitro, NitroRouteRules } from "nitropack/types"; -import { dirname, relative, resolve } from "pathe"; +import { dirname, isAbsolute, relative, resolve } from "pathe"; import { joinURL, withLeadingSlash, withoutLeadingSlash } from "ufo"; import type { PrerenderFunctionConfig, @@ -87,6 +87,16 @@ export async function generateFunctionFiles(nitro: Nitro) { funcDest + ".func" ); + // Guard against a handlerRoute that resolves to (or inside) the server + // bundle itself — e.g. `/__fallback`. Otherwise the `rm` below would delete + // the source bundle before it can be copied. + const relToServer = relative(nitro.options.output.serverDir, funcDir); + if (!relToServer.startsWith("..") && !isAbsolute(relToServer)) { + throw new Error( + `[vercel] Invalid \`vercel.queues.handlerRoute\` (\`${handlerRoute}\`).` + ); + } + // The Vercel preset symlinks every route to the fallback function, but a // queue consumer needs its own `.vc-config.json` with `experimentalTriggers`. // Replace any existing entry with a real copy of the server bundle so the diff --git a/test/unit/vercel.queues.test.ts b/test/unit/vercel.queues.test.ts index 033980d7ee..e715f67324 100644 --- a/test/unit/vercel.queues.test.ts +++ b/test/unit/vercel.queues.test.ts @@ -2,7 +2,7 @@ import { promises as fsp } from "node:fs"; import { tmpdir } from "node:os"; import type { Nitro } from "nitropack/types"; import { join, resolve } from "pathe"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { generateFunctionFiles } from "../../src/presets/vercel/utils"; // These tests exercise the build output directly via `generateFunctionFiles` @@ -35,7 +35,9 @@ describe("vercel queues build output", () => { _prerenderedRoutes: [], }) as unknown as Nitro; - beforeAll(async () => { + // Fresh temp dir per test so generated `.func` directories can't leak + // between assertions. + beforeEach(async () => { outDir = await fsp.mkdtemp(join(tmpdir(), "nitro-vercel-queues-")); serverDir = resolve(outDir, "functions/__fallback.func"); await fsp.mkdir(serverDir, { recursive: true }); @@ -46,7 +48,7 @@ describe("vercel queues build output", () => { ); }); - afterAll(async () => { + afterEach(async () => { await fsp.rm(outDir, { recursive: true, force: true }); }); @@ -143,6 +145,20 @@ describe("vercel queues build output", () => { ).toBeDefined(); }); + it("throws (without deleting the bundle) if handlerRoute targets the server dir", async () => { + await expect( + generateFunctionFiles( + createNitroStub({ + queues: { handlerRoute: "/__fallback", triggers: [{ topic: "x" }] }, + }) + ) + ).rejects.toThrow(/handlerRoute/); + + // The source bundle must remain intact. + const index = await fsp.readFile(resolve(serverDir, "index.mjs"), "utf8"); + expect(index).toContain("export default"); + }); + it("does not create a consumer function without triggers", async () => { await generateFunctionFiles(createNitroStub({})); @@ -151,6 +167,13 @@ describe("vercel queues build output", () => { .then((r) => JSON.parse(r)); const routes = buildConfig.routes as { src?: string }[]; expect(routes.some((r) => r.src?.includes("/_vercel/queues/"))).toBe(false); + + // No consumer function directory should be generated. + const exists = await fsp + .access(resolve(outDir, "functions/_vercel/queues/consumer.func")) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); }); // With a modern compat date, a catch-all (`/**`) handler produces an