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/.changeset/queue-client-0-5-0.md b/.changeset/queue-client-0-5-0.md new file mode 100644 index 0000000000..a1c6bfff58 --- /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` diff --git a/docs/content/worlds/v4/postgres.mdx b/docs/content/worlds/v4/postgres.mdx index 6120ad7e1e..6a39495a02 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. 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 82757b83fd..caff939582 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. 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/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index ae03ffabb6..89d7c980e6 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 non-bundlable Vercel packages while preserving user externals', async () => { const config = withWorkflow({ serverExternalPackages: ['@node-rs/xxhash'], }); @@ -196,10 +196,11 @@ describe('withWorkflow builder config', () => { defaultConfig: {}, }); + // @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', - '@workflow/world-vercel', - '@vercel/queue', '@vercel/oidc', '@vercel/cli-auth', '@napi-rs/keyring', @@ -207,6 +208,68 @@ 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', + // 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( + 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 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: {}, + }); + + expect(nextConfig.serverExternalPackages).toContain('@vercel/queue'); + }); + + it('bundles a @vercel/queue version that carries the bundler-ignore hints', async () => { + // 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: {}, + }); + + expect(nextConfig.serverExternalPackages).not.toContain('@vercel/queue'); + }); + + 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,8 +349,6 @@ describe('withWorkflow builder config', () => { expect(resolvedConfig.serverExternalPackages).toEqual([ 'plain-external-a', - '@workflow/world-vercel', - '@vercel/queue', '@vercel/oidc', '@vercel/cli-auth', '@napi-rs/keyring', @@ -298,11 +359,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', + '@vercel/queue', ], }); @@ -351,8 +412,6 @@ describe('withWorkflow builder config', () => { expect(resolvedConfig.serverExternalPackages).toEqual([ 'plain-external-b', - '@workflow/world-vercel', - '@vercel/queue', '@vercel/oidc', '@vercel/cli-auth', '@napi-rs/keyring', @@ -363,11 +422,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', + '@vercel/queue', ], }); expect(warnSpy).not.toHaveBeenCalled(); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 98a3e465cf..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 { @@ -11,13 +11,36 @@ import semver from 'semver'; import { getNextBuilder } from './builder.js'; const VERCEL_WORLD_PACKAGE = '@workflow/world-vercel'; -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', ]; -const VERCEL_WORLD_SERVER_EXTERNAL_PACKAGES = [ +// `@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, ]; @@ -235,6 +258,64 @@ function resolveNextVersion(workingDir: string): string { ); } +/** + * 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 { + 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); @@ -412,10 +493,8 @@ 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, + ...(isBundlableQueueVersion(process.cwd()) ? [] : [QUEUE_PACKAGE]), ]), ]; const existingCompiler = nextConfig.compiler ?? {}; @@ -532,7 +611,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, + ]), ], }); })(); 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