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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-vercel-world-bundle.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/queue-client-0-5-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/world-vercel': patch
---

Update `@vercel/queue` to `0.5.0`
8 changes: 8 additions & 0 deletions docs/content/worlds/v4/postgres.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<Callout type="warn">
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.
</Callout>

<Tabs items={["Next.js", "SvelteKit", "Nitro"]}>

<Tab value="Next.js">
Expand Down
8 changes: 8 additions & 0 deletions docs/content/worlds/v5/postgres.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<Callout type="warn">
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.
</Callout>

<Tabs items={["Next.js", "SvelteKit", "Nitro"]}>

<Tab value="Next.js">
Expand Down
81 changes: 70 additions & 11 deletions packages/next/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
});
Expand All @@ -196,17 +196,80 @@ 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',
]);
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' }];
Expand Down Expand Up @@ -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',
Expand All @@ -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',
],
});

Expand Down Expand Up @@ -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',
Expand All @@ -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();
Expand Down
98 changes: 90 additions & 8 deletions packages/next/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 ?? {};
Expand Down Expand Up @@ -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,
]),
],
});
})();
Expand Down
30 changes: 10 additions & 20 deletions packages/world-vercel/src/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -46,7 +36,6 @@ const {
return {
mockSend,
MockConsumerDiscoveryError,
MockDuplicateMessageError,
MockQueueClient,
mockHandleCallback,
};
Expand All @@ -55,7 +44,6 @@ const {
vi.mock('@vercel/queue', () => ({
QueueClient: MockQueueClient,
ConsumerDiscoveryError: MockConsumerDiscoveryError,
DuplicateMessageError: MockDuplicateMessageError,
}));

vi.mock('./utils.js', () => ({
Expand Down Expand Up @@ -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';
Expand All @@ -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;
Expand Down
Loading
Loading