From a271b520a63910e94a4f6c001d5f43e65f95c91d Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 12 Aug 2026 10:13:15 -0700 Subject: [PATCH 1/6] [next] Bundle the Vercel world into the Next.js server output `withWorkflow` put `@workflow/world-vercel` in `serverExternalPackages` alongside `@vercel/queue` and friends. The stated reason was to keep bundlers out of `@vercel/queue`'s keyring dependency tree, but that only ever applied to the queue side: no published `@vercel/queue` or `@vercel/oidc` version depends on `@vercel/cli-auth` or `@napi-rs/keyring`. Leaving the world external means its whole module graph is resolved from disk at runtime. On a cold start that lands in front of the first request, and it is not small: importing `workflow/runtime` from a server-startup hook resolved ~440 files, ~250 of them from the world's own subtree. Bundle the world and keep only the queue external. `@vercel/queue` has to stay external for a separate, still-current reason: it performs a fully dynamic `import(absolutePath)` that Turbopack rejects with "server relative imports are not implemented yet". Measured on a Next.js 15.5 app installed from npm (M4 Pro, warm fs), target world `vercel`: - `register()` in `instrumentation.ts`: 155ms -> 104ms (turbopack), 160ms -> 97ms (webpack) - flow route module evaluation: 118ms -> 103ms - API surface and `specVersion` of the resulting world are unchanged The workspace workbenches cannot show this difference: pnpm links the world out of `node_modules`, so Next.js bundles it either way. Only a real install reproduces the external path. Also flag the cold-start cost on the Postgres world's "Starting the World" section, which is the snippet that gets copied into apps that do not need it. --- .changeset/lazy-vercel-world-bundle.md | 5 +++++ docs/content/worlds/v4/postgres.mdx | 8 ++++++++ docs/content/worlds/v5/postgres.mdx | 8 ++++++++ packages/next/src/index.test.ts | 22 ++++++++++++++++------ packages/next/src/index.ts | 26 +++++++++++++++++++++----- 5 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 .changeset/lazy-vercel-world-bundle.md diff --git a/.changeset/lazy-vercel-world-bundle.md b/.changeset/lazy-vercel-world-bundle.md new file mode 100644 index 0000000000..8dedcf1c64 --- /dev/null +++ b/.changeset/lazy-vercel-world-bundle.md @@ -0,0 +1,5 @@ +--- +'@workflow/next': patch +--- + +Bundle `@workflow/world-vercel` into the Next.js server output instead of leaving it external, so cold starts no longer resolve its module graph from disk one file at a time. diff --git a/docs/content/worlds/v4/postgres.mdx b/docs/content/worlds/v4/postgres.mdx index 6120ad7e1e..4a4d125321 100644 --- a/docs/content/worlds/v4/postgres.mdx +++ b/docs/content/worlds/v4/postgres.mdx @@ -75,6 +75,14 @@ The migration is idempotent and can safely be run as a post-deployment lifecycle To subscribe to the graphile-worker queue, your workflow app needs to start the world on server start. Here are examples for a few frameworks: + +This step is specific to worlds that run a background worker, such as Postgres +World. Do not copy it into an app deployed to Vercel: the Vercel world has no +`start()`, so the call is a no-op, and importing `workflow/runtime` from a +server-startup hook pulls the whole runtime into the cold-start path before the +first request is served. + + diff --git a/docs/content/worlds/v5/postgres.mdx b/docs/content/worlds/v5/postgres.mdx index 82757b83fd..c471c25cd5 100644 --- a/docs/content/worlds/v5/postgres.mdx +++ b/docs/content/worlds/v5/postgres.mdx @@ -84,6 +84,14 @@ The migration is idempotent and can safely be run as a post-deployment lifecycle To subscribe to the graphile-worker queue, your workflow app needs to start the world on server start. Here are examples for a few frameworks: + +This step is specific to worlds that run a background worker, such as Postgres +World. Do not copy it into an app deployed to Vercel: the Vercel world has no +`start()`, so the call is a no-op, and importing `workflow/runtime` from a +server-startup hook pulls the whole runtime into the cold-start path before the +first request is served. + + diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index ae03ffabb6..5e29b9e5df 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -187,7 +187,7 @@ describe('withWorkflow builder config', () => { }); }); - it('externalizes the built-in Vercel world while preserving user externals', async () => { + it('externalizes the Vercel world dependencies while preserving user externals', async () => { const config = withWorkflow({ serverExternalPackages: ['@node-rs/xxhash'], }); @@ -196,9 +196,11 @@ describe('withWorkflow builder config', () => { defaultConfig: {}, }); + // @workflow/world-vercel is deliberately absent: it is bundled into the + // server output so a cold start does not resolve its module graph from + // disk one file at a time. expect(nextConfig.serverExternalPackages).toEqual([ '@node-rs/xxhash', - '@workflow/world-vercel', '@vercel/queue', '@vercel/oidc', '@vercel/cli-auth', @@ -207,6 +209,16 @@ describe('withWorkflow builder config', () => { expect(nextConfig.outputFileTracingIncludes).toBeUndefined(); }); + it('keeps the Vercel world external for the workflow/step bundles', async () => { + const config = withWorkflow({}); + + await config('phase-production-build', { defaultConfig: {} }); + + expect(builderConfigs[0].externalPackages).toEqual( + expect.arrayContaining(['@workflow/world-vercel', '@vercel/queue']) + ); + }); + it('preserves user webpack externals without adding Vercel world dependency externals', async () => { const userWebpack = vi.fn((webpackConfig: any) => { webpackConfig.externals = [{ react: 'commonjs react' }]; @@ -286,7 +298,6 @@ describe('withWorkflow builder config', () => { expect(resolvedConfig.serverExternalPackages).toEqual([ 'plain-external-a', - '@workflow/world-vercel', '@vercel/queue', '@vercel/oidc', '@vercel/cli-auth', @@ -298,11 +309,11 @@ describe('withWorkflow builder config', () => { 'server-only', 'client-only', 'plain-external-a', - '@workflow/world-vercel', '@vercel/queue', '@vercel/oidc', '@vercel/cli-auth', '@napi-rs/keyring', + '@workflow/world-vercel', ], }); @@ -351,7 +362,6 @@ describe('withWorkflow builder config', () => { expect(resolvedConfig.serverExternalPackages).toEqual([ 'plain-external-b', - '@workflow/world-vercel', '@vercel/queue', '@vercel/oidc', '@vercel/cli-auth', @@ -363,11 +373,11 @@ describe('withWorkflow builder config', () => { 'server-only', 'client-only', 'plain-external-b', - '@workflow/world-vercel', '@vercel/queue', '@vercel/oidc', '@vercel/cli-auth', '@napi-rs/keyring', + '@workflow/world-vercel', ], }); expect(warnSpy).not.toHaveBeenCalled(); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 98a3e465cf..f9298126c6 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -11,13 +11,29 @@ import semver from 'semver'; import { getNextBuilder } from './builder.js'; const VERCEL_WORLD_PACKAGE = '@workflow/world-vercel'; +// `@vercel/queue` performs a fully dynamic `import(absolutePath)`, which +// Turbopack cannot resolve ("server relative imports are not implemented +// yet"), so it has to stay external. `@vercel/oidc` is reached through it, and +// `@vercel/cli-auth`/`@napi-rs/keyring` are only present in workspaces that +// also depend on the CLI; keeping them external avoids bundlers walking into a +// native dependency tree that never runs on a server. const VERCEL_WORLD_DEPENDENCY_PACKAGES = [ '@vercel/queue', '@vercel/oidc', '@vercel/cli-auth', '@napi-rs/keyring', ]; -const VERCEL_WORLD_SERVER_EXTERNAL_PACKAGES = [ +// Only the dependencies above are externalized for the Next.js server bundle. +// `@workflow/world-vercel` itself is bundled: every module it pulls in that is +// left external has to be resolved from disk on a cold start, one file at a +// time, and `register()` in `instrumentation.ts` sits in front of the first +// request. Bundling it cuts the module count for that import from ~440 to +// ~250 and takes ~50ms off `register()`. +const VERCEL_WORLD_SERVER_EXTERNAL_PACKAGES = VERCEL_WORLD_DEPENDENCY_PACKAGES; +// The workflow/step bundles are built by esbuild, which resolves +// `@vercel/queue`'s dynamic import fine but has no reason to inline the world +// either. Keep the previous behavior there. +const WORKFLOW_BUNDLE_EXTERNAL_PACKAGES = [ VERCEL_WORLD_PACKAGE, ...VERCEL_WORLD_DEPENDENCY_PACKAGES, ]; @@ -412,9 +428,6 @@ export function withWorkflow( nextConfig.serverExternalPackages = [ ...new Set([ ...(nextConfig.serverExternalPackages || []), - // Keep the Vercel world and its native-prone dependencies external so - // local builds do not try to parse @vercel/queue's keyring dependency - // tree. ...VERCEL_WORLD_SERVER_EXTERNAL_PACKAGES, ]), ]; @@ -532,7 +545,10 @@ export function withWorkflow( // See: https://nextjs.org/docs/app/getting-started/server-and-client-components 'server-only', 'client-only', - ...effectiveServerExternalPackages, + ...new Set([ + ...effectiveServerExternalPackages, + ...WORKFLOW_BUNDLE_EXTERNAL_PACKAGES, + ]), ], }); })(); From 576022d89c2f98738eec21ae5d2d76768b8cbe95 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 12 Aug 2026 18:13:45 -0700 Subject: [PATCH 2/6] [docs] Do not hinge the startup-hook warning on Vercel lacking start() A no-op `start()` for the Vercel world is in flight (#2544), so phrasing the warning as "the Vercel world has no start()" would go stale the moment that lands. The cost being warned about is the import, not the call: pulling in `workflow/runtime` from a server-startup hook is what puts the runtime in front of the first request, whether or not there is a `start()` to call at the end of it. Co-Authored-By: Claude Opus 5 --- docs/content/worlds/v4/postgres.mdx | 8 ++++---- docs/content/worlds/v5/postgres.mdx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/content/worlds/v4/postgres.mdx b/docs/content/worlds/v4/postgres.mdx index 4a4d125321..6a39495a02 100644 --- a/docs/content/worlds/v4/postgres.mdx +++ b/docs/content/worlds/v4/postgres.mdx @@ -77,10 +77,10 @@ To subscribe to the graphile-worker queue, your workflow app needs to start the This step is specific to worlds that run a background worker, such as Postgres -World. Do not copy it into an app deployed to Vercel: the Vercel world has no -`start()`, so the call is a no-op, and importing `workflow/runtime` from a -server-startup hook pulls the whole runtime into the cold-start path before the -first request is served. +World. Worlds whose queue delivers work over HTTP, including the Vercel world, +have no worker to subscribe, so `start()` does nothing there while the import +still costs you: pulling in `workflow/runtime` from a server-startup hook puts +the whole runtime into the cold-start path before the first request is served. diff --git a/docs/content/worlds/v5/postgres.mdx b/docs/content/worlds/v5/postgres.mdx index c471c25cd5..caff939582 100644 --- a/docs/content/worlds/v5/postgres.mdx +++ b/docs/content/worlds/v5/postgres.mdx @@ -86,10 +86,10 @@ To subscribe to the graphile-worker queue, your workflow app needs to start the This step is specific to worlds that run a background worker, such as Postgres -World. Do not copy it into an app deployed to Vercel: the Vercel world has no -`start()`, so the call is a no-op, and importing `workflow/runtime` from a -server-startup hook pulls the whole runtime into the cold-start path before the -first request is served. +World. Worlds whose queue delivers work over HTTP, including the Vercel world, +have no worker to subscribe, so `start()` does nothing there while the import +still costs you: pulling in `workflow/runtime` from a server-startup hook puts +the whole runtime into the cold-start path before the first request is served. From c69202fdfd079d71b93ee711e99037467d8c9fff Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 19 Aug 2026 12:38:11 -0700 Subject: [PATCH 3/6] [world-vercel] Bundle the queue client on @vercel/queue 0.5.0 0.5.0 annotates the queue client's dynamic `import()` with `turbopackIgnore`/`webpackIgnore`/`@vite-ignore`, so the package can now be inlined into a server bundle instead of being resolved from disk on every cold start. Drop it from the Next.js plugin's external list, keeping only the CLI-shaped dependencies that cannot survive bundling, and guard the change on the resolved queue version so an app pinned to an older copy keeps it external. 0.5.0 also removed `DuplicateMessageError`. The v3 send path never produced it: that response comes from the Dynamo sender, while v3 sends go through Kinesis and dedupe asynchronously, so a repeated `idempotencyKey` returns a fresh message id instead of a conflict. The branch that fabricated a synthetic `msg_duplicate_*` id was unreachable and is gone. Pin the queue client's own `@vercel/oidc` to 3.2.0: 3.3+ depends on `@vercel/cli-config`, whose `xdg-app-paths` dependency instantiates itself at module scope and throws once a bundler inlines it. Co-Authored-By: Claude Opus 5 --- .changeset/queue-client-0-5-0.md | 5 ++ packages/next/src/index.test.ts | 56 +++++++++++++++++---- packages/next/src/index.ts | 66 ++++++++++++++++++------- packages/world-vercel/src/queue.test.ts | 30 ++++------- packages/world-vercel/src/queue.ts | 50 +++++++------------ pnpm-lock.yaml | 31 +++++------- pnpm-workspace.yaml | 8 ++- 7 files changed, 146 insertions(+), 100 deletions(-) create mode 100644 .changeset/queue-client-0-5-0.md diff --git a/.changeset/queue-client-0-5-0.md b/.changeset/queue-client-0-5-0.md new file mode 100644 index 0000000000..a100382439 --- /dev/null +++ b/.changeset/queue-client-0-5-0.md @@ -0,0 +1,5 @@ +--- +'@workflow/world-vercel': patch +--- + +Update `@vercel/queue` to 0.5.0, whose dynamic import carries bundler-ignore hints so the queue client can be bundled by Turbopack, webpack, and Vite. diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 5e29b9e5df..5f014284d3 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -187,7 +187,7 @@ describe('withWorkflow builder config', () => { }); }); - it('externalizes the Vercel world dependencies while preserving user externals', async () => { + it('externalizes the non-bundlable Vercel packages while preserving user externals', async () => { const config = withWorkflow({ serverExternalPackages: ['@node-rs/xxhash'], }); @@ -196,12 +196,11 @@ describe('withWorkflow builder config', () => { defaultConfig: {}, }); - // @workflow/world-vercel is deliberately absent: it is bundled into the - // server output so a cold start does not resolve its module graph from - // disk one file at a time. + // @workflow/world-vercel and @vercel/queue are deliberately absent: they + // are bundled into the server output so a cold start does not resolve their + // module graphs from disk one file at a time. expect(nextConfig.serverExternalPackages).toEqual([ '@node-rs/xxhash', - '@vercel/queue', '@vercel/oidc', '@vercel/cli-auth', '@napi-rs/keyring', @@ -209,6 +208,47 @@ describe('withWorkflow builder config', () => { expect(nextConfig.outputFileTracingIncludes).toBeUndefined(); }); + function writeProjectWithQueueVersion(version: string): string { + const projectDir = mkdtempSync( + join(realTmpDir, 'workflow-next-queue-version-') + ); + writeFile( + join(projectDir, 'node_modules', '@vercel', 'queue', 'package.json'), + JSON.stringify({ + name: '@vercel/queue', + version, + main: 'index.js', + }) + ); + writeFile( + join(projectDir, 'node_modules', '@vercel', 'queue', 'index.js'), + 'module.exports = {};' + ); + return projectDir; + } + + it('keeps an unbundlable @vercel/queue external', async () => { + // Older releases leave their dynamic `import()` unannotated, which Turbopack + // cannot resolve, so bundling that copy would fail the build. + process.chdir(writeProjectWithQueueVersion('0.4.0')); + + const nextConfig = await withWorkflow({})('phase-production-build', { + defaultConfig: {}, + }); + + expect(nextConfig.serverExternalPackages).toContain('@vercel/queue'); + }); + + it('bundles a @vercel/queue version that carries the bundler-ignore hints', async () => { + process.chdir(writeProjectWithQueueVersion('0.5.0')); + + const nextConfig = await withWorkflow({})('phase-production-build', { + defaultConfig: {}, + }); + + expect(nextConfig.serverExternalPackages).not.toContain('@vercel/queue'); + }); + it('keeps the Vercel world external for the workflow/step bundles', async () => { const config = withWorkflow({}); @@ -298,7 +338,6 @@ describe('withWorkflow builder config', () => { expect(resolvedConfig.serverExternalPackages).toEqual([ 'plain-external-a', - '@vercel/queue', '@vercel/oidc', '@vercel/cli-auth', '@napi-rs/keyring', @@ -309,11 +348,11 @@ describe('withWorkflow builder config', () => { 'server-only', 'client-only', 'plain-external-a', - '@vercel/queue', '@vercel/oidc', '@vercel/cli-auth', '@napi-rs/keyring', '@workflow/world-vercel', + '@vercel/queue', ], }); @@ -362,7 +401,6 @@ describe('withWorkflow builder config', () => { expect(resolvedConfig.serverExternalPackages).toEqual([ 'plain-external-b', - '@vercel/queue', '@vercel/oidc', '@vercel/cli-auth', '@napi-rs/keyring', @@ -373,11 +411,11 @@ describe('withWorkflow builder config', () => { 'server-only', 'client-only', 'plain-external-b', - '@vercel/queue', '@vercel/oidc', '@vercel/cli-auth', '@napi-rs/keyring', '@workflow/world-vercel', + '@vercel/queue', ], }); expect(warnSpy).not.toHaveBeenCalled(); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index f9298126c6..de9463e249 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -11,28 +11,35 @@ import semver from 'semver'; import { getNextBuilder } from './builder.js'; const VERCEL_WORLD_PACKAGE = '@workflow/world-vercel'; -// `@vercel/queue` performs a fully dynamic `import(absolutePath)`, which -// Turbopack cannot resolve ("server relative imports are not implemented -// yet"), so it has to stay external. `@vercel/oidc` is reached through it, and -// `@vercel/cli-auth`/`@napi-rs/keyring` are only present in workspaces that -// also depend on the CLI; keeping them external avoids bundlers walking into a -// native dependency tree that never runs on a server. -const VERCEL_WORLD_DEPENDENCY_PACKAGES = [ - '@vercel/queue', +const QUEUE_PACKAGE = '@vercel/queue'; +// Bundling `@vercel/queue` requires the version whose dynamic `import()` carries +// `turbopackIgnore`/`webpackIgnore` annotations. Without them Turbopack cannot +// resolve that import ("server relative imports are not implemented yet") and +// fails the build, so an older copy has to stay external. +const QUEUE_MIN_BUNDLABLE_VERSION = '0.5.0'; +// `@vercel/oidc` reaches `@vercel/cli-config`, and `@vercel/cli-auth` reaches +// both that and `@napi-rs/keyring`. Those are CLI-shaped dependencies that never +// run on a server: `xdg-app-paths` builds its config directory at module scope +// and throws `The "path" argument must be of type string` once bundled, which +// fails page-data collection. Keeping the three external stops any bundler from +// walking into that tree. +const VERCEL_WORLD_SERVER_EXTERNAL_PACKAGES = [ '@vercel/oidc', '@vercel/cli-auth', '@napi-rs/keyring', ]; -// Only the dependencies above are externalized for the Next.js server bundle. -// `@workflow/world-vercel` itself is bundled: every module it pulls in that is -// left external has to be resolved from disk on a cold start, one file at a -// time, and `register()` in `instrumentation.ts` sits in front of the first -// request. Bundling it cuts the module count for that import from ~440 to -// ~250 and takes ~50ms off `register()`. -const VERCEL_WORLD_SERVER_EXTERNAL_PACKAGES = VERCEL_WORLD_DEPENDENCY_PACKAGES; -// The workflow/step bundles are built by esbuild, which resolves -// `@vercel/queue`'s dynamic import fine but has no reason to inline the world -// either. Keep the previous behavior there. +// `@workflow/world-vercel` and the queue client are bundled into the Next.js +// server build. Every module left external has to be resolved from disk on a +// cold start, one file at a time, and `register()` in `instrumentation.ts` sits +// in front of the first request. Bundling the world cuts the module count for +// that import from ~440 to ~250 and takes ~50ms off `register()`. +const VERCEL_WORLD_DEPENDENCY_PACKAGES = [ + QUEUE_PACKAGE, + ...VERCEL_WORLD_SERVER_EXTERNAL_PACKAGES, +]; +// The workflow and step bundles are built by esbuild, which resolves the +// dynamic import fine but gains nothing from inlining the world: those bundles +// are loaded once per invocation either way. const WORKFLOW_BUNDLE_EXTERNAL_PACKAGES = [ VERCEL_WORLD_PACKAGE, ...VERCEL_WORLD_DEPENDENCY_PACKAGES, @@ -251,6 +258,28 @@ function resolveNextVersion(workingDir: string): string { ); } +/** + * Whether the `@vercel/queue` copy the consuming app resolves can be bundled. + * Fails open: when the package is not resolvable from the app root, the app does + * not import it directly and only `@workflow/world-vercel`'s own pinned copy is + * in the module graph. + */ +function isBundlableQueueVersion(workingDir: string): boolean { + let version: unknown; + try { + const packageJsonPath = require.resolve(`${QUEUE_PACKAGE}/package.json`, { + paths: [workingDir], + }); + version = (require(packageJsonPath) as { version?: unknown }).version; + } catch { + return true; + } + if (typeof version !== 'string' || !semver.valid(version)) { + return true; + } + return semver.gte(version, QUEUE_MIN_BUNDLABLE_VERSION); +} + function fileExists(path: string): boolean { try { const stats = statSync(path); @@ -429,6 +458,7 @@ export function withWorkflow( ...new Set([ ...(nextConfig.serverExternalPackages || []), ...VERCEL_WORLD_SERVER_EXTERNAL_PACKAGES, + ...(isBundlableQueueVersion(process.cwd()) ? [] : [QUEUE_PACKAGE]), ]), ]; const existingCompiler = nextConfig.compiler ?? {}; diff --git a/packages/world-vercel/src/queue.test.ts b/packages/world-vercel/src/queue.test.ts index 8c65bb4238..efa74129c5 100644 --- a/packages/world-vercel/src/queue.test.ts +++ b/packages/world-vercel/src/queue.test.ts @@ -11,19 +11,9 @@ import { const { mockSend, MockConsumerDiscoveryError, - MockDuplicateMessageError, MockQueueClient, mockHandleCallback, } = vi.hoisted(() => { - class MockDuplicateMessageError extends Error { - public readonly idempotencyKey?: string; - constructor(message: string, idempotencyKey?: string) { - super(message); - this.name = 'DuplicateMessageError'; - this.idempotencyKey = idempotencyKey; - } - } - class MockConsumerDiscoveryError extends Error { constructor(message: string) { super(message); @@ -46,7 +36,6 @@ const { return { mockSend, MockConsumerDiscoveryError, - MockDuplicateMessageError, MockQueueClient, mockHandleCallback, }; @@ -55,7 +44,6 @@ const { vi.mock('@vercel/queue', () => ({ QueueClient: MockQueueClient, ConsumerDiscoveryError: MockConsumerDiscoveryError, - DuplicateMessageError: MockDuplicateMessageError, })); vi.mock('./utils.js', () => ({ @@ -266,13 +254,10 @@ describe('createQueue', () => { } }); - it('should silently handle idempotency key conflicts', async () => { - mockSend.mockRejectedValue( - new MockDuplicateMessageError( - 'Duplicate idempotency key detected', - 'my-key' - ) - ); + it('returns the message id for a repeated idempotency key', async () => { + // Repeated keys are accepted and deduplicated after the send, so the + // caller sees an ordinary message id rather than a conflict. + mockSend.mockResolvedValue({ messageId: 'msg-456' }); const originalEnv = process.env.VERCEL_DEPLOYMENT_ID; process.env.VERCEL_DEPLOYMENT_ID = 'dpl_test'; @@ -285,7 +270,12 @@ describe('createQueue', () => { { idempotencyKey: 'my-key' } ); - expect(result.messageId).toBe('msg_duplicate_my-key'); + expect(result.messageId).toBe('msg-456'); + expect(mockSend).toHaveBeenCalledWith( + expect.any(String), + expect.anything(), + expect.objectContaining({ idempotencyKey: 'my-key' }) + ); } finally { if (originalEnv !== undefined) { process.env.VERCEL_DEPLOYMENT_ID = originalEnv; diff --git a/packages/world-vercel/src/queue.ts b/packages/world-vercel/src/queue.ts index bb5a96f7c8..cb662f6bdd 100644 --- a/packages/world-vercel/src/queue.ts +++ b/packages/world-vercel/src/queue.ts @@ -1,10 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { Transport } from '@vercel/queue'; -import { - ConsumerDiscoveryError, - DuplicateMessageError, - QueueClient, -} from '@vercel/queue'; +import { ConsumerDiscoveryError, QueueClient } from '@vercel/queue'; import { MessageId, type Queue, @@ -483,34 +479,22 @@ export function createQueue(config?: APIConfig): Queue { /[^A-Za-z0-9-_]/g, '-' ); - try { - const { messageId } = await client.send(sanitizedQueueName, wrapper, { - idempotencyKey: opts?.idempotencyKey, - delaySeconds: opts?.delaySeconds, - headers: { - ...getHeadersFromPayload(payload), - ...opts?.headers, - }, - }); - return { - // messageId may be null when VQS fails over to a different region — - // the event is ingested but the responding region cannot return an ID. - messageId: messageId ? MessageId.parse(messageId) : null, - }; - } catch (error) { - // Silently handle idempotency key conflicts - the message was already queued. - // This matches the behavior of world-local and world-postgres. - if (error instanceof DuplicateMessageError) { - // Return a placeholder messageId since the original is not available from the error. - // Callers using idempotency keys shouldn't depend on the returned messageId. - return { - messageId: MessageId.parse( - `msg_duplicate_${error.idempotencyKey ?? opts?.idempotencyKey ?? 'unknown'}` - ), - }; - } - throw error; - } + // A repeated `idempotencyKey` is accepted rather than rejected: the send + // returns a fresh message ID and only one of the messages is delivered, so + // there is no conflict for the caller to handle here. + const { messageId } = await client.send(sanitizedQueueName, wrapper, { + idempotencyKey: opts?.idempotencyKey, + delaySeconds: opts?.delaySeconds, + headers: { + ...getHeadersFromPayload(payload), + ...opts?.headers, + }, + }); + return { + // messageId may be null when the queue fails over to a different region: + // the event is ingested but the responding region cannot return an ID. + messageId: messageId ? MessageId.parse(messageId) : null, + }; }; const createQueueHandler: Queue['createQueueHandler'] = ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 462c3c4b08..e80390e1da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,8 +22,8 @@ catalogs: specifier: 3.2.0 version: 3.2.0 '@vercel/queue': - specifier: 0.4.0 - version: 0.4.0 + specifier: 0.5.0 + version: 0.5.0 '@vitest/coverage-v8': specifier: ^4.1.10 version: 4.1.10 @@ -62,6 +62,7 @@ catalogs: version: 4.3.6 overrides: + '@vercel/queue>@vercel/oidc': 3.2.0 '@opentelemetry/api': 1.9.1 rfc6902: 5.1.2 devalue: 5.9.0 @@ -1367,7 +1368,7 @@ importers: dependencies: '@vercel/queue': specifier: 'catalog:' - version: 0.4.0(@opentelemetry/api@1.9.1) + version: 0.5.0(@opentelemetry/api@1.9.1) '@workflow/errors': specifier: workspace:* version: link:../errors @@ -1422,7 +1423,7 @@ importers: dependencies: '@vercel/queue': specifier: 'catalog:' - version: 0.4.0(@opentelemetry/api@1.9.1) + version: 0.5.0(@opentelemetry/api@1.9.1) '@workflow/errors': specifier: workspace:* version: link:../errors @@ -1569,7 +1570,7 @@ importers: version: 3.2.0 '@vercel/queue': specifier: 'catalog:' - version: 0.4.0(@opentelemetry/api@1.9.1) + version: 0.5.0(@opentelemetry/api@1.9.1) '@workflow/errors': specifier: workspace:* version: link:../errors @@ -9833,8 +9834,8 @@ packages: resolution: {integrity: sha512-6pjdXyNfdCQnj1nyeB1rPtll/XUhmciyeZJD0rpIUUwmcIfR+utDl6+iFvlHvsBdqAVT8UC6ydObT0v+xldfUQ==} engines: {node: '>=20.0.0'} - '@vercel/queue@0.4.0': - resolution: {integrity: sha512-2HctPb2+6Pj1DMxbPohMq574rE9OP3fUAGhwV0R4Qv1FHQIR+qti92G8FGjFMJGUSFxVlWdNwVfgHU2hZlM6Dw==} + '@vercel/queue@0.5.0': + resolution: {integrity: sha512-TqvoMuhZK9Hw2yal6ee6EZ+wQw55+qPrpev6O6jJrpJMC1myrCrt7bLZvb9GQDIR35h40EiETVaMZT4eFWBVpA==} engines: {node: '>=20.0.0'} peerDependencies: '@opentelemetry/api': 1.9.1 @@ -10616,10 +10617,6 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} - engines: {node: 18 || 20 || >=22} - brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -26294,13 +26291,13 @@ snapshots: '@vercel/queue@0.3.1': dependencies: - '@vercel/oidc': 3.8.2 + '@vercel/oidc': 3.2.0 minimatch: 10.2.5 mixpart: 0.0.6 picocolors: 1.1.1 optional: true - '@vercel/queue@0.4.0(@opentelemetry/api@1.9.1)': + '@vercel/queue@0.5.0(@opentelemetry/api@1.9.1)': dependencies: '@vercel/oidc': 3.2.0 minimatch: 10.2.5 @@ -27601,10 +27598,6 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.4: - dependencies: - balanced-match: 4.0.4 - brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -29591,7 +29584,7 @@ snapshots: glob@13.0.6: dependencies: - minimatch: 10.2.4 + minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 @@ -31361,7 +31354,7 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.4 + brace-expansion: 5.0.6 minimatch@10.2.5: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ba8c3d839c..3879de4bde 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,7 +12,7 @@ catalog: "@types/node": 22.19.0 "@vercel/functions": ^3.8.0 "@vercel/oidc": 3.2.0 - "@vercel/queue": 0.4.0 + "@vercel/queue": 0.5.0 "@vitest/coverage-v8": ^4.1.10 "@vitest/runner": ^4.1.10 ai: 6.0.116 @@ -30,6 +30,12 @@ onlyBuiltDependencies: - esbuild overrides: + # `@vercel/queue` accepts any `@vercel/oidc` in the 3.x line, and 3.3+ pulls in + # `@vercel/cli-config`, whose `xdg-app-paths` dependency instantiates itself at + # module scope and throws `The "path" argument must be of type string` once a + # bundler inlines it. Pin the queue client's copy to the last dependency-free + # release so a bundled queue client keeps working. + '@vercel/queue>@vercel/oidc': 3.2.0 # Single @opentelemetry/api instance workspace-wide: with two resolved # copies (1.9.0 + 1.9.1), the copy that registers the tracer provider and # the copy a package imports can differ, and the API's global-registration From af860ff617549731ef9d711e30e84e2ecce63277 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 19 Aug 2026 13:31:36 -0700 Subject: [PATCH 4/6] [next] Read the installed @vercel/queue version from disk `require.resolve('@vercel/queue/package.json')` always throws: the published package's `exports` map declares only `.`, so Node answers a manifest subpath with ERR_PACKAGE_PATH_NOT_EXPORTED. The version gate therefore always failed open and bundled the queue client regardless of the installed version. Read the manifest from the nearest `node_modules` copy instead, and give the fixture the same `exports` map the published package has so a resolver-based lookup cannot pass against it. Co-Authored-By: Claude Opus 5 --- packages/next/src/index.test.ts | 17 +++++++-- packages/next/src/index.ts | 66 +++++++++++++++++++++++++-------- 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 5f014284d3..89d7c980e6 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -218,6 +218,15 @@ describe('withWorkflow builder config', () => { name: '@vercel/queue', version, main: 'index.js', + // The published package exports `.` and nothing else, so asking Node + // for the manifest by subpath throws. Mirroring the map here keeps a + // resolver-based lookup from passing against a fixture that the real + // package layout would reject. + exports: { + '.': { + require: './index.js', + }, + }, }) ); writeFile( @@ -229,8 +238,9 @@ describe('withWorkflow builder config', () => { it('keeps an unbundlable @vercel/queue external', async () => { // Older releases leave their dynamic `import()` unannotated, which Turbopack - // cannot resolve, so bundling that copy would fail the build. - process.chdir(writeProjectWithQueueVersion('0.4.0')); + // cannot resolve, so bundling that copy risks failing the build. Any version + // below the bundlable floor works here, without restating the floor. + process.chdir(writeProjectWithQueueVersion('0.0.1')); const nextConfig = await withWorkflow({})('phase-production-build', { defaultConfig: {}, @@ -240,7 +250,8 @@ describe('withWorkflow builder config', () => { }); it('bundles a @vercel/queue version that carries the bundler-ignore hints', async () => { - process.chdir(writeProjectWithQueueVersion('0.5.0')); + // Any version above the bundlable floor, without restating the floor here. + process.chdir(writeProjectWithQueueVersion('99.0.0')); const nextConfig = await withWorkflow({})('phase-production-build', { defaultConfig: {}, diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index de9463e249..da0feac73c 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -1,4 +1,4 @@ -import { copyFileSync, mkdirSync, statSync } from 'node:fs'; +import { copyFileSync, mkdirSync, readFileSync, statSync } from 'node:fs'; import { copyFile, mkdir, readFile } from 'node:fs/promises'; import { dirname, isAbsolute, join } from 'node:path'; import { @@ -259,27 +259,63 @@ function resolveNextVersion(workingDir: string): string { } /** - * Whether the `@vercel/queue` copy the consuming app resolves can be bundled. - * Fails open: when the package is not resolvable from the app root, the app does - * not import it directly and only `@workflow/world-vercel`'s own pinned copy is - * in the module graph. + * Whether the `@vercel/queue` copy in the app's dependency tree can be bundled. + * Fails open when no version can be read: the copy in the graph is then the one + * `@workflow/world-vercel` pins, which ships alongside this plugin. */ function isBundlableQueueVersion(workingDir: string): boolean { - let version: unknown; - try { - const packageJsonPath = require.resolve(`${QUEUE_PACKAGE}/package.json`, { - paths: [workingDir], - }); - version = (require(packageJsonPath) as { version?: unknown }).version; - } catch { - return true; - } - if (typeof version !== 'string' || !semver.valid(version)) { + const version = readInstalledQueueVersion(workingDir); + if (version === undefined || !semver.valid(version)) { return true; } return semver.gte(version, QUEUE_MIN_BUNDLABLE_VERSION); } +/** + * The installed `@vercel/queue` version, read from the nearest `node_modules` + * copy at or above the app directory. + * + * The manifest is read from disk rather than looked up with + * `require.resolve('@vercel/queue/package.json')`: the published package's + * `exports` map declares only `.`, so Node answers a manifest subpath with + * `ERR_PACKAGE_PATH_NOT_EXPORTED` even when the package is installed. Resolving + * the package entry instead is no better, because a bundler or a test runner can + * substitute a resolver that answers from the toolchain's module graph rather + * than the app's. + * + * Returns undefined for a layout that keeps the package out of a reachable + * `node_modules` directory, which includes any pnpm app that does not depend on + * the queue client directly. Those layouts cannot externalize it by name under + * webpack either, so the caller bundles it. + */ +function readInstalledQueueVersion(workingDir: string): string | undefined { + let directory = workingDir; + let previous = ''; + while (directory !== previous) { + const manifestPath = join( + directory, + 'node_modules', + ...QUEUE_PACKAGE.split('/'), + 'package.json' + ); + if (fileExists(manifestPath)) { + try { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + version?: unknown; + }; + return typeof manifest.version === 'string' + ? manifest.version + : undefined; + } catch { + return undefined; + } + } + previous = directory; + directory = dirname(directory); + } + return undefined; +} + function fileExists(path: string): boolean { try { const stats = statSync(path); From 278419cc8ae35f92db36eec77df367ce60545f5c Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 19 Aug 2026 13:58:03 -0700 Subject: [PATCH 5/6] Re-trigger CI Vercel registered no GitHub deployment resource for four of the eighteen projects on af860ff617, so the deployment-wait step in the docs smoke job and in the nextjs-webpack and nuxt Vercel Prod lanes had nothing to poll. From 8d77de18f37f786c26ada11f914220af099f0e63 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 19 Aug 2026 14:37:27 -0700 Subject: [PATCH 6/6] Apply suggestion from @VaguelySerious Signed-off-by: Peter Wielander --- .changeset/queue-client-0-5-0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/queue-client-0-5-0.md b/.changeset/queue-client-0-5-0.md index a100382439..a1c6bfff58 100644 --- a/.changeset/queue-client-0-5-0.md +++ b/.changeset/queue-client-0-5-0.md @@ -2,4 +2,4 @@ '@workflow/world-vercel': patch --- -Update `@vercel/queue` to 0.5.0, whose dynamic import carries bundler-ignore hints so the queue client can be bundled by Turbopack, webpack, and Vite. +Update `@vercel/queue` to `0.5.0`