diff --git a/.changeset/framework-shared-config.md b/.changeset/framework-shared-config.md
new file mode 100644
index 0000000000..ae5e46e94d
--- /dev/null
+++ b/.changeset/framework-shared-config.md
@@ -0,0 +1,7 @@
+---
+"@workflow/nest": minor
+"@workflow/next": minor
+"@workflow/nitro": minor
+---
+
+Add typed shared configuration support to the Next.js, Nitro, and Nest integrations.
diff --git a/.changeset/lazy-world-factories.md b/.changeset/lazy-world-factories.md
new file mode 100644
index 0000000000..aad0065a71
--- /dev/null
+++ b/.changeset/lazy-world-factories.md
@@ -0,0 +1,7 @@
+---
+"@workflow/config": minor
+"@workflow/world": minor
+"@workflow/world-postgres": patch
+---
+
+Add typed Workflow configuration with module-based lazy World providers.
diff --git a/.changeset/shared-config-runtime.md b/.changeset/shared-config-runtime.md
new file mode 100644
index 0000000000..d9ea52ef75
--- /dev/null
+++ b/.changeset/shared-config-runtime.md
@@ -0,0 +1,8 @@
+---
+"@workflow/builders": minor
+"@workflow/cli": minor
+"@workflow/core": minor
+"workflow": minor
+---
+
+Bundle configured World modules and queue settings across runtime, build, and CLI entry points.
diff --git a/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx b/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx
index acfc585691..c7059f9150 100644
--- a/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx
@@ -7,8 +7,6 @@ prerequisites:
- /docs/getting-started/next
---
-Configures webpack/turbopack loaders to transform workflow code (`"use step"`/`"use workflow"` directives)
-
## Usage
To enable `"use step"` and `"use workflow"` directives while developing locally or deploying to production, wrap your `nextConfig` with `withWorkflow`.
@@ -16,15 +14,12 @@ To enable `"use step"` and `"use workflow"` directives while developing locally
```typescript title="next.config.ts" lineNumbers
import { withWorkflow } from "workflow/next"; // [!code highlight]
import type { NextConfig } from "next";
-
+
const nextConfig: NextConfig = {
// … rest of your Next.js config
};
-// not required but allows configuring workflow options
-const workflowConfig = {}
-
-export default withWorkflow(nextConfig, workflowConfig); // [!code highlight]
+export default withWorkflow(nextConfig); // [!code highlight]
```
@@ -82,7 +77,9 @@ Use the smallest directory that contains every workspace package imported by you
## Options
-`withWorkflow` accepts an optional second argument to configure the Next.js integration.
+Use [`workflow.config.ts`](/docs/foundations/configuration) for shared build and
+Next.js settings. The optional second argument to `withWorkflow` overrides
+environment variables and `workflow.config.ts`.
```typescript title="next.config.ts" lineNumbers
import type { NextConfig } from "next";
@@ -102,7 +99,7 @@ export default withWorkflow(nextConfig, {
| Option | Type | Default | Description |
| --- | --- | --- | --- |
-| `workflows.local.port` | `number` | — | Overrides the `PORT` environment variable for local development. Has no effect when deployed to Vercel. |
+| `workflows.local.port` | `number` | — | Sets the local Workflow server port. Has no effect when deployed to Vercel. |
| `workflows.sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. See [Source maps](#source-maps) below. |
### Source maps
@@ -123,7 +120,10 @@ In production, source maps are already off by default. Setting `sourcemap: false
Setting `sourcemap` explicitly affects **all** generated bundles (steps, workflows, webhook). The legacy `WORKFLOW_EMIT_SOURCEMAPS_FOR_DEBUGGING=1` environment variable is narrower — it only toggles source maps on the final workflow wrapper and webhook bundle (which default to off). It continues to work, but new code should use the `sourcemap` option or the `WORKFLOW_SOURCEMAP` environment variable instead.
-The option can also be set via the `WORKFLOW_SOURCEMAP` environment variable, which accepts the same values plus `'0'` / `'1'` as aliases for `false` / `true`. Precedence is: explicit config > `WORKFLOW_SOURCEMAP` > the environment-aware default (`'inline'` in development, `false` in production). Development is detected from `next dev` / `NODE_ENV=development`, so the config option and the env var both let you force either behavior in either environment.
+The option can also be set via the `WORKFLOW_SOURCEMAP` environment variable,
+which accepts the same values plus `'0'` / `'1'` as aliases for `false` /
+`true`. Precedence is: explicit `withWorkflow` option >
+`WORKFLOW_SOURCEMAP` > `workflow.config.ts` > per-bundle default.
The `workflows.local` options only affect local development. When deployed to Vercel, the runtime ignores `local` settings and uses the Vercel world automatically.
@@ -131,11 +131,10 @@ The `workflows.local` options only affect local development. When deployed to Ve
## Exporting a Function
-
If you are exporting a function in your `next.config` you will need to ensure you call the function returned from `withWorkflow`.
```typescript title="next.config.ts" lineNumbers
-import { NextConfig } from "next";
+import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next";
import createNextIntlPlugin from "next-intl/plugin";
diff --git a/docs/content/docs/v5/api-reference/workflow-nitro/index.mdx b/docs/content/docs/v5/api-reference/workflow-nitro/index.mdx
index 0f1a4baeea..4d5f250d4f 100644
--- a/docs/content/docs/v5/api-reference/workflow-nitro/index.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-nitro/index.mdx
@@ -7,7 +7,8 @@ related:
- /docs/getting-started/nitro
---
-Nitro integration for Workflow SDK. The `workflow/nitro` entry point's default export is a [Nitro module](https://v3.nitro.build/guide/modules) — it has no callable API. You enable it by adding it to the `modules` array of your Nitro config and configure it via the `workflow` key.
+Add the [`workflow/nitro` Nitro module](https://v3.nitro.build/guide/modules) to
+transform workflow directives, build bundles, and register runtime routes.
## Usage
@@ -20,6 +21,10 @@ export default defineConfig({
});
```
+Shared build and Nitro settings can also be placed in
+[`workflow.config.ts`](/docs/foundations/configuration). Values under
+`workflow` in `nitro.config.ts` take precedence.
+
When enabled, the module:
- Transforms `"use workflow"` and `"use step"` directives during bundling.
@@ -30,7 +35,7 @@ When enabled, the module:
## Module Options
-Options are read from the `workflow` key of your Nitro config. The option type is exported as `ModuleOptions`:
+The `workflow` key accepts the exported `ModuleOptions` type:
```typescript title="nitro.config.ts" lineNumbers
import { defineConfig } from "nitro";
diff --git a/docs/content/docs/v5/foundations/configuration.mdx b/docs/content/docs/v5/foundations/configuration.mdx
new file mode 100644
index 0000000000..ea17d1d5c7
--- /dev/null
+++ b/docs/content/docs/v5/foundations/configuration.mdx
@@ -0,0 +1,112 @@
+---
+title: Configuration
+description: Configure Workflow SDK with a typed workflow.config.ts file.
+type: conceptual
+summary: Configure the World, builds, and framework integration for your app.
+prerequisites:
+ - /docs/foundations/workflows-and-steps
+related:
+ - /docs/deploying/world/local-world
+ - /docs/deploying/world/postgres-world
+ - /docs/api-reference/workflow-next/with-workflow
+---
+
+Create `workflow.config.ts` in your application:
+
+```typescript title="workflow.config.ts" lineNumbers
+import type { WorkflowConfig } from "workflow/config";
+
+const config = {
+ world: "./workflow.world.ts",
+ build: {
+ dirs: ["workflows"],
+ sourcemap: false,
+ },
+ integration: {
+ type: "next",
+ local: { port: 4000 },
+ },
+} satisfies WorkflowConfig;
+
+export default config;
+```
+
+The World module default-exports a lazy provider:
+
+```typescript title="workflow.world.ts" lineNumbers
+import type { WorldProvider } from "workflow/config";
+import { createWorld } from "@workflow/world-postgres";
+
+const world: WorldProvider = () =>
+ createWorld({
+ connectionString: process.env.WORKFLOW_POSTGRES_URL!,
+ jobPrefix: "myapp_",
+ queueConcurrency: 50,
+ maxPoolSize: 52,
+ });
+
+export default world;
+```
+
+
+ Next.js projects also wrap `next.config.ts` with `withWorkflow()`.
+
+
+In a monorepo, place a config file in each application that needs its own
+settings.
+
+## Precedence
+
+From highest to lowest priority:
+
+1. Explicit CLI flags or framework options
+2. Environment variables
+3. `workflow.config.ts`
+4. Built-in defaults
+
+## World
+
+`world` is a module specifier relative to `workflow.config.ts`, or a package
+name. Its default export must satisfy `WorldProvider`. The provider runs only
+when the application first needs its World.
+
+## Worlds by Environment
+
+Choose a World inside the factory based on the runtime environment:
+
+```typescript title="workflow.world.ts" lineNumbers
+import type { WorldProvider } from "workflow/config";
+import { createLocalWorld } from "@workflow/world-local";
+import { createWorld as createPostgresWorld } from "@workflow/world-postgres";
+
+const world: WorldProvider = () => {
+ switch (process.env.NODE_ENV) {
+ case "production":
+ return createPostgresWorld({
+ connectionString: process.env.WORKFLOW_POSTGRES_URL!,
+ });
+ case "development":
+ case "test":
+ return createLocalWorld();
+ default:
+ throw new Error(`Unexpected NODE_ENV: ${process.env.NODE_ENV}`);
+ }
+};
+
+export default world;
+```
+
+## Integration Settings
+
+Use `integration` for framework-specific settings. Set `type` to the framework
+you are configuring:
+
+{/* @skip-typecheck: mutually exclusive config fragments */}
+
+```typescript
+// Next.js
+integration: { type: "next", local: { port: 4000 } }
+
+// Nitro
+integration: { type: "nitro", typescriptPlugin: true, runtime: "nodejs24.x" }
+```
diff --git a/docs/content/docs/v5/foundations/meta.json b/docs/content/docs/v5/foundations/meta.json
index ce49cfe47b..d808f012b0 100644
--- a/docs/content/docs/v5/foundations/meta.json
+++ b/docs/content/docs/v5/foundations/meta.json
@@ -9,6 +9,7 @@
"cancellation",
"serialization",
"idempotency",
+ "configuration",
"versioning"
],
"defaultOpen": true
diff --git a/packages/builders/package.json b/packages/builders/package.json
index 91a5eb78b7..70d66abca2 100644
--- a/packages/builders/package.json
+++ b/packages/builders/package.json
@@ -40,10 +40,12 @@
},
"dependencies": {
"@swc/core": "catalog:",
+ "@workflow/config": "workspace:*",
"@workflow/core": "workspace:*",
"@workflow/errors": "workspace:*",
"@workflow/utils": "workspace:*",
"@workflow/swc-plugin": "workspace:*",
+ "@workflow/world": "workspace:*",
"builtin-modules": "5.0.0",
"chalk": "5.6.2",
"enhanced-resolve": "catalog:",
diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts
index 062d61cc46..87712087d6 100644
--- a/packages/builders/src/base-builder.ts
+++ b/packages/builders/src/base-builder.ts
@@ -24,8 +24,8 @@ import {
import { createWorkflowEntrypointOptionsCode } from './constants.js';
import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js';
import {
- fastDiscoverEntries,
type DiscoveredEntries,
+ fastDiscoverEntries,
} from './fast-discovery.js';
import {
getImportPath,
@@ -228,6 +228,29 @@ export abstract class BaseBuilder {
return this.config.moduleSpecifierRoot || this.transformProjectRoot;
}
+ protected get queueNamespace(): string | undefined {
+ return (
+ process.env.WORKFLOW_QUEUE_NAMESPACE ??
+ this.config.workflowConfig?.config.queue?.namespace
+ );
+ }
+
+ private get runtimeConfigPlugins(): esbuild.Plugin[] {
+ const path = this.config.workflowConfig?.runtimePath;
+ if (!path) return [];
+ return [
+ {
+ name: 'workflow-runtime-config',
+ setup(build) {
+ build.onResolve(
+ { filter: /^@workflow\/config\/runtime-binding$/ },
+ () => ({ path })
+ );
+ },
+ },
+ ];
+ }
+
/**
* Whether informational BaseBuilder logs should be printed.
* Subclasses can override this to silence progress logs while keeping warnings/errors.
@@ -1352,11 +1375,11 @@ export const __steps_registered = true;
`${Date.now() - bundleStartTime}ms`
);
- if (this.config.workflowManifestPath) {
- const resolvedPath = resolve(
- process.cwd(),
- this.config.workflowManifestPath
- );
+ const workflowManifestPath =
+ this.config.workflowManifestPath ??
+ this.config.workflowConfig?.config.build?.manifest?.output;
+ if (workflowManifestPath) {
+ const resolvedPath = this.resolvePath(workflowManifestPath);
let prefix = '';
if (resolvedPath.endsWith('.cjs')) {
@@ -1425,8 +1448,9 @@ export const __steps_registered = true;
}
}
- const workflowEntrypointOptionsCode =
- createWorkflowEntrypointOptionsCode();
+ const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode(
+ { namespace: this.queueNamespace }
+ );
const bundleFinal = async (interimBundle: string) => {
const workflowBundleCode = interimBundle;
@@ -1480,6 +1504,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
keepNames: true,
minify: false,
external: ['@aws-sdk/credential-provider-web-identity'],
+ plugins: this.runtimeConfigPlugins,
});
this.logEsbuildMessages(
@@ -1615,7 +1640,9 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
// 3. Generate combined route file
const stepsRelativePath = './' + basename(stepsOutfile).replace(/\\/g, '/');
const escapedVMCode = workflowVMCode.replace(/[\\`$]/g, '\\$&');
- const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode();
+ const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode({
+ namespace: this.queueNamespace,
+ });
const combinedFunctionCode = `// biome-ignore-all lint: generated file
/* eslint-disable */
@@ -1661,6 +1688,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
minify: false,
define: importMetaDefine,
external: ['@aws-sdk/credential-provider-web-identity'],
+ plugins: this.runtimeConfigPlugins,
});
this.logEsbuildMessages(finalResult, 'combined bundle', true);
this.logBaseBuilderInfo(
@@ -1685,8 +1713,9 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
// Create a custom bundleFinal for watch mode that uses workflowEntrypoint
const combinedBundleFinal = async (interimBundleText: string) => {
const escaped = interimBundleText.replace(/[\\`$]/g, '\\$&');
- const workflowEntrypointOptionsCode =
- createWorkflowEntrypointOptionsCode();
+ const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode(
+ { namespace: this.queueNamespace }
+ );
const code = `// biome-ignore-all lint: generated file
/* eslint-disable */
import { __steps_registered } from '${stepsRelativePath}';
@@ -1938,6 +1967,7 @@ export const OPTIONS = handler;`;
mainFields: ['module', 'main'],
// Don't externalize anything - bundle everything including workflow packages
external: [],
+ plugins: this.runtimeConfigPlugins,
});
this.logEsbuildMessages(result, 'webhook bundle creation');
@@ -2074,10 +2104,14 @@ export const OPTIONS = handler;`;
/**
* Whether the manifest should be exposed as a public HTTP route.
- * Controlled by the `WORKFLOW_PUBLIC_MANIFEST` environment variable.
+ * WORKFLOW_PUBLIC_MANIFEST takes precedence over workflow.config.ts.
*/
protected get shouldExposePublicManifest(): boolean {
- return process.env.WORKFLOW_PUBLIC_MANIFEST === '1';
+ if (process.env.WORKFLOW_PUBLIC_MANIFEST !== undefined) {
+ return process.env.WORKFLOW_PUBLIC_MANIFEST === '1';
+ }
+
+ return this.config.workflowConfig?.config.build?.manifest?.public ?? false;
}
/**
@@ -2126,14 +2160,16 @@ export const OPTIONS = handler;`;
/**
* Resolve the effective source map mode for a given call site. Precedence:
- * explicit `sourcemap` config > `WORKFLOW_SOURCEMAP` env var > the call
- * site's default. Returned value is passed directly to esbuild's
- * `sourcemap` option.
+ * builder option > WORKFLOW_SOURCEMAP > workflow.config.ts > the call site's
+ * default. Returned value is passed directly to esbuild's `sourcemap`
+ * option.
*/
protected resolveSourcemap(defaultMode: SourcemapMode): SourcemapMode {
if (this.config.sourcemap !== undefined) return this.config.sourcemap;
const envMode = parseSourcemapEnv(process.env.WORKFLOW_SOURCEMAP);
if (envMode !== undefined) return envMode;
+ const configMode = this.config.workflowConfig?.config.build?.sourcemap;
+ if (configMode !== undefined) return configMode;
return defaultMode;
}
diff --git a/packages/builders/src/config-helpers.ts b/packages/builders/src/config-helpers.ts
index e07975e26c..456c0701d0 100644
--- a/packages/builders/src/config-helpers.ts
+++ b/packages/builders/src/config-helpers.ts
@@ -1,4 +1,5 @@
import { readFile } from 'node:fs/promises';
+import type { LoadedWorkflowConfig } from '@workflow/config/load';
import { findUp } from 'find-up';
import JSON5 from 'json5';
import type { SourcemapMode, WorkflowConfig } from './types.js';
@@ -97,6 +98,7 @@ export function createBaseBuilderConfig(options: {
externalPackages?: string[];
runtime?: string;
sourcemap?: SourcemapMode;
+ workflowConfig?: LoadedWorkflowConfig;
}): Omit {
return {
dirs: options.dirs ?? ['workflows'],
@@ -109,5 +111,6 @@ export function createBaseBuilderConfig(options: {
externalPackages: options.externalPackages,
runtime: options.runtime,
sourcemap: options.sourcemap,
+ workflowConfig: options.workflowConfig,
};
}
diff --git a/packages/builders/src/resolve-sourcemap.test.ts b/packages/builders/src/resolve-sourcemap.test.ts
index 774e8a7a36..fabee8e785 100644
--- a/packages/builders/src/resolve-sourcemap.test.ts
+++ b/packages/builders/src/resolve-sourcemap.test.ts
@@ -30,7 +30,7 @@ class TestBuilder extends BaseBuilder {
function createBuilder(
sourcemap?: SourcemapMode,
- watch?: boolean
+ options: { watch?: boolean; workflowSourcemap?: SourcemapMode } = {}
): TestBuilder {
const config: StandaloneConfig = {
buildTarget: 'standalone',
@@ -40,7 +40,15 @@ function createBuilder(
workflowsBundlePath: '',
webhookBundlePath: '',
sourcemap,
- watch,
+ watch: options.watch,
+ workflowConfig:
+ options.workflowSourcemap === undefined
+ ? undefined
+ : {
+ path: '/tmp/workflow.config.ts',
+ runtimePath: '/tmp/runtime-config.mjs',
+ config: { build: { sourcemap: options.workflowSourcemap } },
+ },
};
return new TestBuilder(config);
}
@@ -84,6 +92,15 @@ describe('resolveSourcemap', () => {
);
});
+ it('prefers environment variable over workflow.config.ts', () => {
+ process.env.WORKFLOW_SOURCEMAP = 'inline';
+ expect(
+ createBuilder(undefined, {
+ workflowSourcemap: false,
+ }).callResolveSourcemap(true)
+ ).toBe('inline');
+ });
+
it('uses environment variable when config is not set', () => {
process.env.WORKFLOW_SOURCEMAP = 'false';
expect(createBuilder().callResolveSourcemap('inline')).toBe(false);
@@ -152,7 +169,7 @@ describe('defaultSourcemapMode / isDevelopmentBuild', () => {
it('defaults to inline when config.watch is true', () => {
// Even with a production NODE_ENV, an active watch/dev server opts in.
process.env.NODE_ENV = 'production';
- const builder = createBuilder(undefined, true);
+ const builder = createBuilder(undefined, { watch: true });
expect(builder.publicIsDevelopmentBuild).toBe(true);
expect(builder.publicDefaultSourcemapMode).toBe('inline');
});
@@ -194,7 +211,9 @@ describe('sourcemapsEnabled', () => {
});
it('is true by default in development (watch)', () => {
- expect(createBuilder(undefined, true).publicSourcemapsEnabled).toBe(true);
+ expect(
+ createBuilder(undefined, { watch: true }).publicSourcemapsEnabled
+ ).toBe(true);
});
it('is true by default in development (NODE_ENV)', () => {
diff --git a/packages/builders/src/types.ts b/packages/builders/src/types.ts
index 74fcd3b885..3543c8275b 100644
--- a/packages/builders/src/types.ts
+++ b/packages/builders/src/types.ts
@@ -1,3 +1,8 @@
+import type { SourcemapMode } from '@workflow/config';
+import type { LoadedWorkflowConfig } from '@workflow/config/load';
+
+export type { SourcemapMode } from '@workflow/config';
+
export const validBuildTargets = [
'standalone',
'vercel-build-output-api',
@@ -8,18 +13,6 @@ export const validBuildTargets = [
] as const;
export type BuildTarget = (typeof validBuildTargets)[number];
-/**
- * Source map emission mode for generated workflow bundles. Matches esbuild's
- * `sourcemap` option vocabulary:
- *
- * - `true` / `'linked'`: write a separate `.map` file and add a `sourceMappingURL` comment
- * - `'inline'`: emit a base64-encoded source map at the end of the bundle
- * - `'external'`: write a separate `.map` file without the comment
- * - `'both'`: emit both inline and external source maps
- * - `false`: omit source maps entirely
- */
-export type SourcemapMode = boolean | 'inline' | 'linked' | 'external' | 'both';
-
/**
* Common configuration options shared across all builder types.
*/
@@ -49,6 +42,8 @@ interface BaseWorkflowConfig {
workflowManifestPath?: string;
+ workflowConfig?: LoadedWorkflowConfig;
+
// Optional prefix for debug files (e.g., "_" for Astro to ignore them)
debugFilePrefix?: string;
@@ -91,7 +86,8 @@ interface BaseWorkflowConfig {
* them out of the function bundle.
*
* Can also be set via the `WORKFLOW_SOURCEMAP` environment variable;
- * config wins over env var, env var wins over the default.
+ * an explicit builder option wins over the env var, which wins over
+ * workflow.config.ts and the default.
*/
sourcemap?: SourcemapMode;
}
diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts
index 03fb55ffdf..db6ff9644b 100644
--- a/packages/builders/src/vercel-build-output-api.ts
+++ b/packages/builders/src/vercel-build-output-api.ts
@@ -1,7 +1,7 @@
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { BaseBuilder } from './base-builder.js';
-import { WORKFLOW_QUEUE_TRIGGER } from './constants.js';
+import { createWorkflowQueueTrigger } from './constants.js';
export class VercelBuildOutputAPIBuilder extends BaseBuilder {
async build(): Promise {
@@ -36,7 +36,9 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {
// serves no purpose without maps.
shouldAddSourcemapSupport: this.sourcemapsEnabled,
maxDuration: 'max',
- experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER],
+ experimentalTriggers: [
+ createWorkflowQueueTrigger({ namespace: this.queueNamespace }),
+ ],
runtime: this.config.runtime,
});
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 3ddf8df1be..53c86baa80 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -45,6 +45,7 @@
"@swc/core": "catalog:",
"@vercel/cli-auth": "0.0.1",
"@workflow/builders": "workspace:*",
+ "@workflow/config": "workspace:*",
"@workflow/core": "workspace:*",
"@workflow/errors": "workspace:*",
"@workflow/swc-plugin": "workspace:*",
diff --git a/packages/cli/src/base.ts b/packages/cli/src/base.ts
index ace29f4055..6b83f90913 100644
--- a/packages/cli/src/base.ts
+++ b/packages/cli/src/base.ts
@@ -1,5 +1,5 @@
import { Command } from '@oclif/core';
-import { getWorld } from '@workflow/core/runtime';
+import { closeWorld } from '@workflow/core/runtime';
async function flushStream(stream: NodeJS.WriteStream): Promise {
if (
@@ -37,8 +37,7 @@ export abstract class BaseCommand extends Command {
*/
async finally(err: Error | undefined): Promise {
try {
- const world = await getWorld();
- await world.close?.();
+ await closeWorld();
} catch (closeErr) {
this.warn(
`Failed to close world: ${closeErr instanceof Error ? closeErr.message : String(closeErr)}`
diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts
index 61c13663c2..f2e28bef11 100644
--- a/packages/cli/src/commands/build.ts
+++ b/packages/cli/src/commands/build.ts
@@ -26,7 +26,9 @@ export default class Build extends BaseCommand {
'workflow-manifest': Flags.string({
char: 'm',
description: 'output location for workflow manifest',
- default: '',
+ }),
+ config: Flags.string({
+ description: 'path to a Workflow config file',
}),
};
@@ -69,9 +71,10 @@ export default class Build extends BaseCommand {
this.logInfo(`Using target: ${buildTarget}`);
- const config = getWorkflowConfig({
+ const config = await getWorkflowConfig({
buildTarget: buildTarget as BuildTarget,
workflowManifest: flags['workflow-manifest'],
+ configFile: flags.config,
});
try {
diff --git a/packages/cli/src/lib/config/workflow-config.ts b/packages/cli/src/lib/config/workflow-config.ts
index 1ea91cc4cf..8ea0c0cc53 100644
--- a/packages/cli/src/lib/config/workflow-config.ts
+++ b/packages/cli/src/lib/config/workflow-config.ts
@@ -1,7 +1,9 @@
-import type { BuildTarget, WorkflowConfig } from './types.js';
import { resolve } from 'node:path';
+import { loadWorkflowConfig } from '@workflow/config/load';
+import { config as loadDotEnv } from 'dotenv';
+import type { BuildTarget, WorkflowConfig } from './types.js';
-function resolveObservabilityCwd(): string {
+export function resolveWorkflowCwd(): string {
const raw = process.env.WORKFLOW_OBSERVABILITY_CWD;
if (!raw) {
return process.cwd();
@@ -11,21 +13,38 @@ function resolveObservabilityCwd(): string {
return resolve(process.cwd(), raw);
}
-export const getWorkflowConfig = (
- {
- buildTarget,
- workflowManifest,
- }: {
+export const getWorkflowConfig = async (
+ options: {
buildTarget?: BuildTarget;
workflowManifest?: string;
- } = {
- buildTarget: 'standalone',
- }
-) => {
+ configFile?: string;
+ } = {}
+): Promise => {
+ const { buildTarget = 'standalone', workflowManifest, configFile } = options;
+ const workingDir = resolveWorkflowCwd();
+ loadDotEnv({
+ path: resolve(workingDir, '.env.local'),
+ quiet: true,
+ });
+ loadDotEnv({
+ path: resolve(workingDir, '.env'),
+ quiet: true,
+ });
+
+ const loadedConfig = await loadWorkflowConfig({
+ cwd: workingDir,
+ configFile,
+ });
+ const fileConfig = loadedConfig.config;
const config: WorkflowConfig = {
- dirs: ['./workflows'],
- workingDir: resolveObservabilityCwd(),
- buildTarget: buildTarget as BuildTarget,
+ dirs: fileConfig.build?.dirs ?? ['./workflows'],
+ workingDir,
+ projectRoot: fileConfig.build?.projectRoot
+ ? resolve(workingDir, fileConfig.build.projectRoot)
+ : undefined,
+ externalPackages: fileConfig.build?.externalPackages,
+ workflowConfig: loadedConfig,
+ buildTarget,
stepsBundlePath: './.well-known/workflow/v1/step.mjs',
workflowsBundlePath: './.well-known/workflow/v1/flow.mjs',
webhookBundlePath: './.well-known/workflow/v1/webhook.mjs',
diff --git a/packages/cli/src/lib/inspect/env.ts b/packages/cli/src/lib/inspect/env.ts
index a659e81785..5b6b2d8d92 100644
--- a/packages/cli/src/lib/inspect/env.ts
+++ b/packages/cli/src/lib/inspect/env.ts
@@ -2,7 +2,7 @@ import { access } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { findWorkflowDataDir } from '@workflow/utils/check-data-dir';
import { logger } from '../config/log.js';
-import { getWorkflowConfig } from '../config/workflow-config.js';
+import { resolveWorkflowCwd } from '../config/workflow-config.js';
import { getAuthToken } from './auth.js';
import { fetchTeamInfo } from './vercel-api.js';
import {
@@ -80,7 +80,7 @@ async function findManifestPath(cwd: string) {
*/
export const inferLocalWorldEnvVars = async () => {
const envVars = getEnvVars();
- const cwd = getWorkflowConfig().workingDir;
+ const cwd = resolveWorkflowCwd();
let repoRoot: string | undefined;
// Always expose the effective working directory to the web UI/server-side helpers.
@@ -158,7 +158,7 @@ export const inferLocalWorldEnvVars = async () => {
};
export const inferVercelProjectAndTeam = async () => {
- const cwd = getWorkflowConfig().workingDir;
+ const cwd = resolveWorkflowCwd();
let project: ProjectLink | null = null;
try {
logger.debug(`Inferring project and team from CWD: ${cwd}`);
diff --git a/packages/config/README.md b/packages/config/README.md
new file mode 100644
index 0000000000..a40c97714f
--- /dev/null
+++ b/packages/config/README.md
@@ -0,0 +1,8 @@
+# @workflow/config
+
+Typed, shared configuration for Workflow SDK.
+
+The public API is exposed through `workflow/config`.
+
+See the [configuration guide](https://workflow-sdk.dev/v5/docs/foundations/configuration)
+for the available settings.
diff --git a/packages/config/package.json b/packages/config/package.json
new file mode 100644
index 0000000000..112cd6fa1f
--- /dev/null
+++ b/packages/config/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "@workflow/config",
+ "version": "5.0.0-beta.0",
+ "description": "Typed configuration for Workflow SDK",
+ "type": "module",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "files": [
+ "dist"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./load": {
+ "import": {
+ "types": "./dist/load.d.ts",
+ "default": "./dist/load.js"
+ },
+ "require": {
+ "types": "./dist/load.d.cts",
+ "default": "./dist/load.cjs"
+ }
+ },
+ "./runtime": {
+ "types": "./dist/runtime.d.ts",
+ "default": "./dist/runtime.js"
+ },
+ "./runtime-binding": {
+ "types": "./dist/runtime-binding.d.ts",
+ "default": "./dist/runtime-binding.js"
+ }
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "license": "Apache-2.0",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/vercel/workflow.git",
+ "directory": "packages/config"
+ },
+ "scripts": {
+ "build": "tsc",
+ "clean": "tsc --build --clean && rm -rf dist",
+ "dev": "tsc --watch",
+ "test": "vitest run src",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@workflow/world": "workspace:*",
+ "find-up": "7.0.0",
+ "jiti": "2.7.0",
+ "zod": "catalog:"
+ },
+ "devDependencies": {
+ "@types/node": "catalog:",
+ "@workflow/tsconfig": "workspace:*",
+ "vitest": "catalog:"
+ }
+}
diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts
new file mode 100644
index 0000000000..1d2a8cf617
--- /dev/null
+++ b/packages/config/src/index.ts
@@ -0,0 +1,4 @@
+export type { WorldProvider } from '@workflow/world';
+export type { SourcemapMode, WorkflowConfig } from './schema.js';
+export type WorkflowConfigLoader =
+ typeof import('./load.js').loadWorkflowConfig;
diff --git a/packages/config/src/load.cts b/packages/config/src/load.cts
new file mode 100644
index 0000000000..44d877d0bf
--- /dev/null
+++ b/packages/config/src/load.cts
@@ -0,0 +1,11 @@
+import type {
+ LoadedWorkflowConfig,
+ LoadWorkflowConfigOptions,
+} from './load.js';
+
+export async function loadWorkflowConfig(
+ options: LoadWorkflowConfigOptions
+): Promise {
+ const loader = await import('./load.js');
+ return loader.loadWorkflowConfig(options);
+}
diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts
new file mode 100644
index 0000000000..a6c7f5d06e
--- /dev/null
+++ b/packages/config/src/load.test.ts
@@ -0,0 +1,210 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { afterEach, describe, expect, it } from 'vitest';
+import { loadWorkflowConfig } from './load.js';
+import type { RuntimeWorkflowConfig } from './runtime-binding.js';
+import { WorkflowConfigSchema } from './schema.js';
+
+const tempDirs: string[] = [];
+
+function createProject(files: Record): string {
+ const project = mkdtempSync(join(tmpdir(), 'workflow-config-'));
+ tempDirs.push(project);
+
+ for (const [file, contents] of Object.entries(files)) {
+ const path = join(project, file);
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, contents, 'utf8');
+ }
+
+ return project;
+}
+
+afterEach(() => {
+ delete (globalThis as { __workflowWorldImports?: number })
+ .__workflowWorldImports;
+ for (const dir of tempDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+describe('loadWorkflowConfig', () => {
+ it('loads the nearest TypeScript config without merging parents', async () => {
+ const project = createProject({
+ 'workflow.config.ts': `export default { build: { dirs: ['parent'] } };`,
+ 'apps/web/workflow.config.ts': `export default {
+ build: { dirs: ['app'], sourcemap: false },
+ integration: { type: 'next', local: { port: 4321 } }
+ };`,
+ });
+ const app = join(project, 'apps', 'web');
+
+ const loaded = await loadWorkflowConfig({
+ cwd: app,
+ integration: 'next',
+ });
+
+ expect(loaded.path).toBe(join(app, 'workflow.config.ts'));
+ expect(loaded.config).toEqual({
+ build: { dirs: ['app'], sourcemap: false },
+ integration: { type: 'next', local: { port: 4321 } },
+ });
+ });
+
+ it('generates a runtime binding without loading the World module', async () => {
+ const project = createProject({
+ 'workflow.world.ts': `throw new Error('must stay lazy');`,
+ 'workflow.config.ts': `export default {
+ world: './workflow.world.ts',
+ build: { dirs: ['jobs'] },
+ queue: { namespace: 'app' }
+ };`,
+ });
+
+ const loaded = await loadWorkflowConfig({ cwd: project });
+ const runtimeSource = readFileSync(loaded.runtimePath as string, 'utf8');
+
+ expect(loaded.config.world).toBe('./workflow.world.ts');
+ expect(runtimeSource).toContain('workflow.world.ts');
+ expect(runtimeSource).toContain('queue: {"namespace":"app"}');
+ expect(runtimeSource).not.toContain("dirs: ['jobs']");
+ });
+
+ it('loads the World module only when its provider runs', async () => {
+ const globals = globalThis as typeof globalThis & {
+ __workflowWorldImports?: number;
+ };
+ const project = createProject({
+ 'workflow.world.mjs': `
+globalThis.__workflowWorldImports = (globalThis.__workflowWorldImports ?? 0) + 1;
+export default () => ({});
+`,
+ 'workflow.config.ts': `export default { world: './workflow.world.mjs' };`,
+ });
+ const loaded = await loadWorkflowConfig({ cwd: project });
+
+ const runtime = (await import(
+ pathToFileURL(loaded.runtimePath as string).href
+ )) as {
+ default: RuntimeWorkflowConfig;
+ };
+ expect(globals.__workflowWorldImports).toBeUndefined();
+
+ await runtime.default.world?.();
+ expect(globals.__workflowWorldImports).toBe(1);
+ });
+
+ it('validates World package specifiers', async () => {
+ const project = createProject({
+ 'node_modules/community-world/package.json': JSON.stringify({
+ name: 'community-world',
+ type: 'module',
+ exports: { import: './index.js' },
+ }),
+ 'node_modules/community-world/index.js': `export default () => ({});`,
+ 'workflow.config.ts': `export default { world: 'community-world' };`,
+ });
+
+ const loaded = await loadWorkflowConfig({ cwd: project });
+
+ expect(readFileSync(loaded.runtimePath as string, 'utf8')).toContain(
+ 'import("community-world")'
+ );
+ });
+
+ it('rejects missing World modules', async () => {
+ const project = createProject({
+ 'workflow.config.ts': `export default { world: './missing.ts' };`,
+ });
+
+ await expect(loadWorkflowConfig({ cwd: project })).rejects.toThrow(
+ 'World module not found: ./missing.ts'
+ );
+ });
+
+ it('rejects multiple config files in one directory', async () => {
+ const project = createProject({
+ 'workflow.config.ts': `export default { build: { dirs: ['typescript'] } };`,
+ 'workflow.config.mjs': `export default { build: { dirs: ['javascript'] } };`,
+ });
+
+ await expect(loadWorkflowConfig({ cwd: project })).rejects.toThrow(
+ 'Multiple Workflow config files found'
+ );
+ });
+
+ it('rejects unsupported filenames instead of silently using a parent', async () => {
+ const project = createProject({
+ 'app/workflow.config.json': JSON.stringify({
+ build: { dirs: ['workflows'] },
+ }),
+ });
+ const app = join(project, 'app');
+
+ await expect(loadWorkflowConfig({ cwd: app })).rejects.toThrow(
+ 'Unsupported Workflow config file'
+ );
+ });
+
+ it('rejects integration config for another platform', async () => {
+ const project = createProject({
+ 'workflow.config.ts': `export default { integration: { type: 'nest' } };`,
+ });
+
+ await expect(
+ loadWorkflowConfig({
+ cwd: project,
+ integration: 'next',
+ })
+ ).rejects.toThrow('configures "nest" but was loaded by "next"');
+ });
+
+ it('rejects top-level config functions and unknown keys', async () => {
+ const project = createProject({
+ 'workflow.config.ts': `export default () => ({ build: { dirs: ['workflows'] } });`,
+ });
+
+ await expect(loadWorkflowConfig({ cwd: project })).rejects.toThrow(
+ 'must default-export a static object'
+ );
+
+ const promiseProject = createProject({
+ 'workflow.config.ts': `export default Promise.resolve({ build: { dirs: ['workflows'] } });`,
+ });
+ await expect(loadWorkflowConfig({ cwd: promiseProject })).rejects.toThrow(
+ 'must default-export a static object'
+ );
+
+ expect(() => WorkflowConfigSchema.parse({ unknown: true })).toThrow(
+ 'Unrecognized key'
+ );
+ });
+
+ it('rejects empty single-setting sections', () => {
+ expect(() => WorkflowConfigSchema.parse({ queue: {} })).toThrow();
+ expect(() =>
+ WorkflowConfigSchema.parse({
+ integration: { type: 'next', local: {} },
+ })
+ ).toThrow();
+ });
+
+ it('rejects mixed integration settings', () => {
+ expect(() =>
+ WorkflowConfigSchema.parse({
+ integration: {
+ type: 'next',
+ typescriptPlugin: true,
+ },
+ })
+ ).toThrow();
+ });
+});
diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts
new file mode 100644
index 0000000000..8bd2a27a7b
--- /dev/null
+++ b/packages/config/src/load.ts
@@ -0,0 +1,158 @@
+import assert from 'node:assert/strict';
+import {
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ statSync,
+ writeFileSync,
+} from 'node:fs';
+import {
+ basename,
+ dirname,
+ extname,
+ isAbsolute,
+ join,
+ relative,
+ resolve,
+} from 'node:path';
+import type { WorldProvider } from '@workflow/world';
+import { findUp } from 'find-up';
+import { createJiti } from 'jiti';
+import type { RuntimeWorkflowConfig } from './runtime-binding.js';
+import {
+ type WorkflowConfig,
+ WorkflowConfigSchema,
+ type WorkflowIntegrationType,
+} from './schema.js';
+
+const WORKFLOW_CONFIG_FILES = [
+ 'workflow.config.ts',
+ 'workflow.config.mjs',
+ 'workflow.config.js',
+] as const;
+
+export type LoadWorkflowConfigOptions = {
+ cwd: string;
+ configFile?: string;
+ integration?: WorkflowIntegrationType;
+};
+
+export type LoadedWorkflowConfig =
+ | { path: undefined; runtimePath: undefined; config: WorkflowConfig }
+ | { path: string; runtimePath: string; config: WorkflowConfig };
+
+type FoundWorkflowConfig = Extract;
+
+async function discoverWorkflowConfig({
+ cwd,
+ configFile,
+}: Pick): Promise<
+ string | undefined
+> {
+ if (configFile) {
+ const path = isAbsolute(configFile) ? configFile : resolve(cwd, configFile);
+ assert(
+ ['.ts', '.mjs', '.js'].includes(extname(path)),
+ `Unsupported Workflow config extension "${extname(path)}".`
+ );
+ assert(
+ existsSync(path) && statSync(path).isFile(),
+ `Workflow config file not found: ${path}`
+ );
+ return path;
+ }
+
+ return findUp(
+ (directory) => {
+ const configs = readdirSync(directory).filter((file) =>
+ file.startsWith('workflow.config.')
+ );
+ assert(
+ configs.length <= 1,
+ `Multiple Workflow config files found in ${directory}: ${configs.join(', ')}`
+ );
+
+ const config = configs[0];
+ if (!config) return;
+
+ assert(
+ WORKFLOW_CONFIG_FILES.some((file) => file === config),
+ `Unsupported Workflow config file "${config}".`
+ );
+ return join(directory, config);
+ },
+ { cwd }
+ );
+}
+
+export async function loadWorkflowConfig(
+ options: LoadWorkflowConfigOptions
+): Promise {
+ const path = await discoverWorkflowConfig(options);
+ if (!path) {
+ return { path: undefined, runtimePath: undefined, config: {} };
+ }
+
+ const configModule = await createJiti(import.meta.url, {
+ interopDefault: false,
+ }).import<{ default: unknown }>(path);
+ const rawConfig = configModule.default;
+
+ assert(
+ rawConfig !== null &&
+ typeof rawConfig === 'object' &&
+ Object.getPrototypeOf(rawConfig) === Object.prototype,
+ `${basename(path)} must default-export a static object.`
+ );
+
+ const config = WorkflowConfigSchema.parse(rawConfig);
+ assert(
+ !options.integration ||
+ !config.integration ||
+ config.integration.type === options.integration,
+ `${basename(path)} configures "${config.integration?.type}" but was loaded by "${options.integration}".`
+ );
+
+ const runtimeDir = join(dirname(path), 'node_modules', '.cache', 'workflow');
+ let world = config.world;
+ if (world?.startsWith('.') || (world && isAbsolute(world))) {
+ const worldPath = resolve(dirname(path), world);
+ assert(
+ existsSync(worldPath) && statSync(worldPath).isFile(),
+ `World module not found: ${world}`
+ );
+ world = relative(runtimeDir, worldPath).replaceAll('\\', '/');
+ if (!world.startsWith('.')) world = `./${world}`;
+ } else if (world) {
+ createJiti(path).esmResolve(world);
+ }
+
+ const runtimePath = join(runtimeDir, 'runtime-config.mjs');
+ const worldFactory = world
+ ? `async () => { const provider = (await import(${JSON.stringify(world)})).default; return provider(); }`
+ : 'undefined';
+ mkdirSync(runtimeDir, { recursive: true });
+ writeFileSync(
+ runtimePath,
+ `const world = ${worldFactory};\nexport default { world, queue: ${JSON.stringify(config.queue)} };\n`
+ );
+
+ return { path, runtimePath, config };
+}
+
+export function createRuntimeWorkflowConfig({
+ path,
+ config,
+}: FoundWorkflowConfig): RuntimeWorkflowConfig {
+ if (!config.world) return { queue: config.queue };
+
+ const world = config.world;
+ const jiti = createJiti(path, { interopDefault: false });
+ return {
+ queue: config.queue,
+ world: async () => {
+ const module = await jiti.import<{ default: WorldProvider }>(world);
+ return module.default();
+ },
+ };
+}
diff --git a/packages/config/src/runtime-binding.ts b/packages/config/src/runtime-binding.ts
new file mode 100644
index 0000000000..43c40449f3
--- /dev/null
+++ b/packages/config/src/runtime-binding.ts
@@ -0,0 +1,10 @@
+import type { WorldProvider } from '@workflow/world';
+import type { WorkflowConfig } from './schema.js';
+
+export type RuntimeWorkflowConfig = Pick & {
+ world?: WorldProvider;
+};
+
+const config: RuntimeWorkflowConfig | undefined = undefined;
+
+export default config;
diff --git a/packages/config/src/runtime.ts b/packages/config/src/runtime.ts
new file mode 100644
index 0000000000..3a1feeecf0
--- /dev/null
+++ b/packages/config/src/runtime.ts
@@ -0,0 +1,17 @@
+import type { RuntimeWorkflowConfig } from './runtime-binding.js';
+
+const RuntimeWorkflowConfigSymbol = Symbol.for('@workflow/config/runtime');
+
+const globals = globalThis as typeof globalThis & {
+ [RuntimeWorkflowConfigSymbol]?: RuntimeWorkflowConfig;
+};
+
+export function getRuntimeWorkflowConfig(): RuntimeWorkflowConfig | undefined {
+ return globals[RuntimeWorkflowConfigSymbol];
+}
+
+export function setRuntimeWorkflowConfig(
+ config: RuntimeWorkflowConfig | undefined
+): void {
+ globals[RuntimeWorkflowConfigSymbol] = config;
+}
diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts
new file mode 100644
index 0000000000..3538a89a0f
--- /dev/null
+++ b/packages/config/src/schema.ts
@@ -0,0 +1,61 @@
+import { QueueNamespaceSchema } from '@workflow/world/queue.js';
+import { z } from 'zod/v4';
+
+const sourcemapSchema = z.union([
+ z.boolean(),
+ z.enum(['inline', 'linked', 'external', 'both']),
+]);
+export type SourcemapMode = z.infer;
+
+const integrationSchema = z.discriminatedUnion('type', [
+ z.strictObject({
+ type: z.literal('next'),
+ local: z
+ .strictObject({
+ port: z.number().int().positive().max(65_535),
+ })
+ .optional(),
+ }),
+ z.strictObject({
+ type: z.literal('nitro'),
+ typescriptPlugin: z.boolean().optional(),
+ runtime: z.string().min(1).optional(),
+ }),
+ z.strictObject({
+ type: z.literal('nest'),
+ moduleType: z.enum(['es6', 'commonjs']).optional(),
+ outDir: z.string().min(1).optional(),
+ distDir: z.string().min(1).optional(),
+ watch: z.boolean().optional(),
+ skipBuild: z.boolean().optional(),
+ }),
+]);
+
+export const WorkflowConfigSchema = z.strictObject({
+ world: z.string().min(1).optional(),
+ build: z
+ .strictObject({
+ dirs: z.array(z.string().min(1)).min(1).optional(),
+ projectRoot: z.string().min(1).optional(),
+ externalPackages: z.array(z.string().min(1)).optional(),
+ sourcemap: sourcemapSchema.optional(),
+ manifest: z
+ .strictObject({
+ public: z.boolean().optional(),
+ output: z.string().min(1).optional(),
+ })
+ .optional(),
+ })
+ .optional(),
+ queue: z
+ .strictObject({
+ namespace: QueueNamespaceSchema,
+ })
+ .optional(),
+ integration: integrationSchema.optional(),
+});
+
+export type WorkflowConfig = z.infer;
+export type WorkflowIntegrationType = NonNullable<
+ WorkflowConfig['integration']
+>['type'];
diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json
new file mode 100644
index 0000000000..a78dbf413c
--- /dev/null
+++ b/packages/config/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@workflow/tsconfig/base.json",
+ "compilerOptions": {
+ "outDir": "dist"
+ },
+ "include": ["src"],
+ "exclude": ["node_modules", "**/*.test.ts"]
+}
diff --git a/packages/core/package.json b/packages/core/package.json
index 0c230f26ea..59cb8e3132 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -91,6 +91,7 @@
"@standard-schema/spec": "1.0.0",
"@types/ms": "2.1.0",
"@vercel/functions": "catalog:",
+ "@workflow/config": "workspace:*",
"@workflow/errors": "workspace:*",
"@workflow/serde": "workspace:*",
"@workflow/utils": "workspace:*",
diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts
index 61fe73af8a..690e43f5cc 100644
--- a/packages/core/src/runtime.ts
+++ b/packages/core/src/runtime.ts
@@ -118,6 +118,7 @@ export {
// prevents Turbopack from tracing step-handler.js → get-port.js
// filesystem operations into the flow route bundle.
export {
+ closeWorld,
createWorld,
getWorld,
getWorldHandlers,
diff --git a/packages/core/src/runtime/world-config.test.ts b/packages/core/src/runtime/world-config.test.ts
new file mode 100644
index 0000000000..bdc12ae925
--- /dev/null
+++ b/packages/core/src/runtime/world-config.test.ts
@@ -0,0 +1,52 @@
+import { setRuntimeWorkflowConfig } from '@workflow/config/runtime';
+import type { World } from '@workflow/world';
+import { setWorkflowQueueNamespace } from '@workflow/world/queue.js';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { closeWorld, getWorld, getWorldHandlers } from './world.js';
+
+const targetWorld = process.env.WORKFLOW_TARGET_WORLD;
+
+afterEach(async () => {
+ await closeWorld();
+ setRuntimeWorkflowConfig(undefined);
+ setWorkflowQueueNamespace(undefined);
+ if (targetWorld === undefined) {
+ delete process.env.WORKFLOW_TARGET_WORLD;
+ } else {
+ process.env.WORKFLOW_TARGET_WORLD = targetWorld;
+ }
+});
+
+describe('configured World', () => {
+ it('creates, starts, shares, and closes one lazy World', async () => {
+ delete process.env.WORKFLOW_TARGET_WORLD;
+ const start = vi.fn(async () => {});
+ const close = vi.fn(async () => {});
+ const world = {
+ createQueueHandler: vi.fn(),
+ specVersion: 4,
+ start,
+ close,
+ } as unknown as World;
+ const create = vi.fn(() => world);
+
+ setRuntimeWorkflowConfig({
+ world: create,
+ queue: { namespace: 'app' },
+ });
+
+ expect(create).not.toHaveBeenCalled();
+ const [resolved, handlers] = await Promise.all([
+ getWorld(),
+ getWorldHandlers(),
+ ]);
+
+ expect(resolved).toBe(world);
+ expect(handlers.specVersion).toBe(4);
+ expect(create).toHaveBeenCalledOnce();
+ expect(start).toHaveBeenCalledOnce();
+
+ await closeWorld();
+ expect(close).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts
index fcece0c860..f350f029b8 100644
--- a/packages/core/src/runtime/world.ts
+++ b/packages/core/src/runtime/world.ts
@@ -1,10 +1,13 @@
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
+import { getRuntimeWorkflowConfig } from '@workflow/config/runtime';
+import boundWorkflowConfig from '@workflow/config/runtime-binding';
import {
isVercelWorldTarget,
resolveWorkflowTargetWorld,
} from '@workflow/utils';
import type { World } from '@workflow/world';
+import { setWorkflowQueueNamespace } from '@workflow/world/queue.js';
import { createLocalWorld } from '@workflow/world-local';
import { createVercelWorld } from '@workflow/world-vercel';
@@ -14,24 +17,18 @@ function getRuntimeRequire() {
// dependencies of @workflow/core. Using import.meta.url would resolve
// from core's location, missing app-level packages.
try {
- return createRequire(pathToFileURL(process.cwd() + '/package.json').href);
+ return createRequire(pathToFileURL(`${process.cwd()}/package.json`).href);
} catch {
return createRequire(import.meta.url);
}
}
const WorldCache = Symbol.for('@workflow/world//cache');
-const StubbedWorldCache = Symbol.for('@workflow/world//stubbedCache');
const WorldCachePromise = Symbol.for('@workflow/world//cachePromise');
-const StubbedWorldCachePromise = Symbol.for(
- '@workflow/world//stubbedCachePromise'
-);
const globalSymbols: typeof globalThis & {
[WorldCache]?: World;
- [StubbedWorldCache]?: World;
[WorldCachePromise]?: Promise;
- [StubbedWorldCachePromise]?: Promise;
} = globalThis;
// Dynamic import for custom world modules. Uses a standard import()
@@ -53,7 +50,7 @@ function resolveModulePath(specifier: string): string {
// Relative path - resolve relative to cwd and convert to file:// URL
if (specifier.startsWith('./') || specifier.startsWith('../')) {
return pathToFileURL(
- /* turbopackIgnore: true */ process.cwd() + '/' + specifier
+ /* turbopackIgnore: true */ `${process.cwd()}/${specifier}`
).href;
}
// Package specifier - use require.resolve to find the package
@@ -76,7 +73,7 @@ function resolveModulePath(specifier: string): string {
* vars should call createVercelWorld() directly with an explicit config and
* use setWorld() to inject the instance.
*/
-export const createWorld = async (): Promise => {
+async function createLegacyWorld(): Promise {
const targetWorld = resolveWorkflowTargetWorld();
if (isVercelWorldTarget(targetWorld)) {
@@ -129,36 +126,59 @@ export const createWorld = async (): Promise => {
throw new Error(
`Invalid target world module: ${targetWorld}, must export a default function or createWorld function that returns a World instance.`
);
+}
+
+type ResolvedWorld =
+ | { type: 'configured'; world: World }
+ | { type: 'legacy'; world: World };
+
+async function resolveWorld(): Promise {
+ const config = boundWorkflowConfig ?? getRuntimeWorkflowConfig() ?? {};
+ setWorkflowQueueNamespace(config.queue?.namespace);
+
+ if (process.env.WORKFLOW_TARGET_WORLD) {
+ return {
+ type: 'legacy',
+ world: await createLegacyWorld(),
+ };
+ }
+
+ if (config.world) {
+ return {
+ type: 'configured',
+ world: await config.world(),
+ };
+ }
+
+ return {
+ type: 'legacy',
+ world: await createLegacyWorld(),
+ };
+}
+
+/**
+ * Create a new World instance from WORKFLOW_TARGET_WORLD when set, then
+ * workflow.config.ts, then the environment-aware default.
+ *
+ * This function does not call World.start(). Use getWorld() for the managed
+ * runtime singleton.
+ */
+export const createWorld = async (): Promise => {
+ return (await resolveWorld()).world;
};
export type WorldHandlers = Pick;
/**
- * Some functions from the world are needed at build time, but we do NOT want
- * to cache the world in those instances for general use, since we don't have
- * the correct environment variables set yet. This is a safe function to
- * call at build time, that only gives access to non-environment-bound world
- * functions. The only binding value should be the target world.
- * Once we migrate to a file-based configuration (workflow.config.ts), we should
- * be able to re-combine getWorld and getWorldHandlers into one singleton.
+ * Queue handlers and regular runtime calls share one managed World. The World
+ * factory is never called by config loading or the build integrations; this
+ * path is reached only when host runtime code asks for a handler.
*/
export const getWorldHandlers = async (): Promise => {
- if (globalSymbols[StubbedWorldCache]) {
- return globalSymbols[StubbedWorldCache];
- }
- // Store the promise immediately to prevent race conditions with concurrent calls.
- // Clear on rejection so subsequent calls can retry instead of caching the failure.
- if (!globalSymbols[StubbedWorldCachePromise]) {
- globalSymbols[StubbedWorldCachePromise] = createWorld().catch((err) => {
- globalSymbols[StubbedWorldCachePromise] = undefined;
- throw err;
- });
- }
- const _world = await globalSymbols[StubbedWorldCachePromise];
- globalSymbols[StubbedWorldCache] = _world;
+ const world = await getWorld();
return {
- createQueueHandler: _world.createQueueHandler,
- specVersion: _world.specVersion,
+ createQueueHandler: world.createQueueHandler,
+ specVersion: world.specVersion,
};
};
@@ -169,10 +189,23 @@ export const getWorld = async (): Promise => {
// Store the promise immediately to prevent race conditions with concurrent calls.
// Clear on rejection so subsequent calls can retry instead of caching the failure.
if (!globalSymbols[WorldCachePromise]) {
- globalSymbols[WorldCachePromise] = createWorld().catch((err) => {
- globalSymbols[WorldCachePromise] = undefined;
- throw err;
- });
+ globalSymbols[WorldCachePromise] = resolveWorld()
+ .then(async (resolved) => {
+ switch (resolved.type) {
+ case 'configured':
+ await resolved.world.start?.();
+ return resolved.world;
+ case 'legacy':
+ return resolved.world;
+ default:
+ resolved satisfies never;
+ throw new Error('Unknown World resolution type');
+ }
+ })
+ .catch((err) => {
+ globalSymbols[WorldCachePromise] = undefined;
+ throw err;
+ });
}
globalSymbols[WorldCache] = await globalSymbols[WorldCachePromise];
return globalSymbols[WorldCache];
@@ -184,9 +217,21 @@ export const getWorld = async (): Promise => {
*/
export const setWorld = (world: World | undefined): void => {
globalSymbols[WorldCache] = world;
- globalSymbols[StubbedWorldCache] = world;
globalSymbols[WorldCachePromise] = undefined;
- globalSymbols[StubbedWorldCachePromise] = undefined;
+};
+
+/**
+ * Close the cached World without creating one just for cleanup.
+ */
+export const closeWorld = async (): Promise => {
+ const cachedWorld = globalSymbols[WorldCache];
+ const pendingWorld = globalSymbols[WorldCachePromise];
+
+ globalSymbols[WorldCache] = undefined;
+ globalSymbols[WorldCachePromise] = undefined;
+
+ const world = cachedWorld ?? (pendingWorld ? await pendingWorld : undefined);
+ await world?.close?.();
};
// Register getWorld on globalThis so getWorldLazy can call it directly when
diff --git a/packages/docs-typecheck/src/type-checker.ts b/packages/docs-typecheck/src/type-checker.ts
index f7a19897d4..745e9f8b7f 100644
--- a/packages/docs-typecheck/src/type-checker.ts
+++ b/packages/docs-typecheck/src/type-checker.ts
@@ -70,6 +70,7 @@ const compilerOptions: ts.CompilerOptions = {
// have "require" conditions that TS picks up incorrectly with Bundler resolution.
workflow: [path.join(repoRoot, 'packages/workflow/dist/index')],
'workflow/api': [path.join(repoRoot, 'packages/workflow/dist/api')],
+ 'workflow/config': [path.join(repoRoot, 'packages/workflow/dist/config')],
'workflow/errors': [
path.join(repoRoot, 'packages/workflow/dist/internal/errors'),
],
@@ -100,6 +101,15 @@ const compilerOptions: ts.CompilerOptions = {
'@workflow/serde': [path.join(repoRoot, 'packages/serde/dist/index')],
'@workflow/vitest': [path.join(repoRoot, 'packages/vitest/dist/index')],
'@workflow/world': [path.join(repoRoot, 'packages/world/dist/index')],
+ '@workflow/world-local': [
+ path.join(repoRoot, 'packages/world-local/dist/index'),
+ ],
+ '@workflow/world-postgres': [
+ path.join(repoRoot, 'packages/world-postgres/dist/index'),
+ ],
+ '@workflow/world-vercel': [
+ path.join(repoRoot, 'packages/world-vercel/dist/index'),
+ ],
// Third-party deps available in docs-typecheck/node_modules
zod: [path.join(__dirname, '../node_modules/zod')],
ai: [path.join(__dirname, '../node_modules/ai')],
diff --git a/packages/nest/package.json b/packages/nest/package.json
index 47c8a3142e..d498e9aff2 100644
--- a/packages/nest/package.json
+++ b/packages/nest/package.json
@@ -34,6 +34,8 @@
"dependencies": {
"@swc/core": "catalog:",
"@workflow/builders": "workspace:*",
+ "@workflow/config": "workspace:*",
+ "@workflow/core": "workspace:*",
"@workflow/swc-plugin": "workspace:*",
"pathe": "2.0.3"
},
diff --git a/packages/nest/src/builder.ts b/packages/nest/src/builder.ts
index 817919536a..d03c0be5f2 100644
--- a/packages/nest/src/builder.ts
+++ b/packages/nest/src/builder.ts
@@ -1,6 +1,8 @@
-import { mkdir, writeFile, readFile } from 'node:fs/promises';
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { BaseBuilder, createBaseBuilderConfig } from '@workflow/builders';
-import { join } from 'pathe';
+import type { SourcemapMode } from '@workflow/config';
+import type { LoadedWorkflowConfig } from '@workflow/config/load';
+import { join, resolve } from 'pathe';
import { rewriteTsImportsInContent } from './cjs-rewrite.js';
export interface NestBuilderOptions {
@@ -14,6 +16,14 @@ export interface NestBuilderOptions {
* @default ['src']
*/
dirs?: string[];
+ /**
+ * Project root for package and workspace module resolution.
+ */
+ projectRoot?: string;
+ /**
+ * Packages to leave external in generated workflow bundles.
+ */
+ externalPackages?: string[];
/**
* Output directory for generated workflow bundles
* @default '.nestjs/workflow'
@@ -46,9 +56,13 @@ export interface NestBuilderOptions {
* `'linked'`, `'external'`, `'both'`, or `false` to omit source maps.
* Can also be set via the `WORKFLOW_SOURCEMAP` environment variable.
*/
- sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both';
+ sourcemap?: SourcemapMode;
}
+type NestLocalBuilderOptions = NestBuilderOptions & {
+ workflowConfig?: LoadedWorkflowConfig;
+};
+
export class NestLocalBuilder extends BaseBuilder {
#outDir: string;
#moduleType: 'es6' | 'commonjs';
@@ -56,16 +70,27 @@ export class NestLocalBuilder extends BaseBuilder {
#dirs: string[];
#workingDir: string;
- constructor(options: NestBuilderOptions = {}) {
+ constructor(options: NestLocalBuilderOptions = {}) {
+ const config = options.workflowConfig?.config;
+ const integration =
+ config?.integration?.type === 'nest' ? config.integration : undefined;
+ const build = config?.build;
const workingDir = options.workingDir ?? process.cwd();
- const outDir = options.outDir ?? join(workingDir, '.nestjs/workflow');
- const dirs = options.dirs ?? ['src'];
+ const outDir = resolve(
+ workingDir,
+ options.outDir ?? integration?.outDir ?? '.nestjs/workflow'
+ );
+ const dirs = options.dirs ?? build?.dirs ?? ['src'];
+ const projectRoot = options.projectRoot ?? build?.projectRoot;
super({
...createBaseBuilderConfig({
workingDir,
- watch: options.watch ?? false,
+ watch: options.watch ?? integration?.watch ?? false,
dirs,
+ projectRoot: projectRoot ? resolve(workingDir, projectRoot) : undefined,
+ externalPackages: options.externalPackages ?? build?.externalPackages,
sourcemap: options.sourcemap,
+ workflowConfig: options.workflowConfig,
}),
// Use 'standalone' as base target - we handle the specific bundling ourselves
buildTarget: 'standalone',
@@ -74,8 +99,8 @@ export class NestLocalBuilder extends BaseBuilder {
webhookBundlePath: join(outDir, 'webhook.mjs'),
});
this.#outDir = outDir;
- this.#moduleType = options.moduleType ?? 'es6';
- this.#distDir = options.distDir ?? 'dist';
+ this.#moduleType = options.moduleType ?? integration?.moduleType ?? 'es6';
+ this.#distDir = options.distDir ?? integration?.distDir ?? 'dist';
this.#dirs = dirs;
this.#workingDir = workingDir;
}
diff --git a/packages/nest/src/workflow.controller.ts b/packages/nest/src/workflow.controller.ts
index a040bace25..69c5e634d1 100644
--- a/packages/nest/src/workflow.controller.ts
+++ b/packages/nest/src/workflow.controller.ts
@@ -5,12 +5,17 @@ import { join } from 'pathe';
// Module-level state for configuration
let configuredOutDir: string | null = null;
+let exposePublicManifest = false;
/**
* Configure the workflow controller with the output directory
*/
-export function configureWorkflowController(outDir: string): void {
+export function configureWorkflowController(
+ outDir: string,
+ publicManifest = process.env.WORKFLOW_PUBLIC_MANIFEST === '1'
+): void {
configuredOutDir = outDir;
+ exposePublicManifest = publicManifest;
}
/**
@@ -112,7 +117,7 @@ export class WorkflowController {
@Get('manifest.json')
async handleManifest(@Res() res: any) {
- if (process.env.WORKFLOW_PUBLIC_MANIFEST !== '1') {
+ if (!exposePublicManifest) {
if (typeof res.code === 'function') {
res.code(404).send('');
} else {
diff --git a/packages/nest/src/workflow.module.test.ts b/packages/nest/src/workflow.module.test.ts
new file mode 100644
index 0000000000..29e6d77078
--- /dev/null
+++ b/packages/nest/src/workflow.module.test.ts
@@ -0,0 +1,56 @@
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { getRuntimeWorkflowConfig } from '@workflow/config/runtime';
+import { join, resolve } from 'pathe';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { NestLocalBuilder } from './builder.js';
+import { WorkflowModule } from './workflow.module.js';
+
+const projects: string[] = [];
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ for (const project of projects.splice(0)) {
+ rmSync(project, { recursive: true, force: true });
+ }
+});
+
+describe('WorkflowModule', () => {
+ it('loads workflow.config.ts before building', async () => {
+ const project = mkdtempSync(join(tmpdir(), 'workflow-nest-config-'));
+ projects.push(project);
+ writeFileSync(
+ join(project, 'workflow.world.ts'),
+ `export default () => ({ marker: 'world' });`
+ );
+ writeFileSync(
+ join(project, 'workflow.config.ts'),
+ `export default {
+ world: './workflow.world.ts',
+ build: { dirs: ['src/jobs'], sourcemap: false },
+ integration: { type: 'nest', outDir: '.generated/workflow' }
+};`
+ );
+
+ let builder: NestLocalBuilder | undefined;
+ const build = vi
+ .spyOn(NestLocalBuilder.prototype, 'build')
+ .mockImplementation(async function (this: NestLocalBuilder) {
+ builder = this;
+ });
+ const module = new WorkflowModule({ workingDir: project });
+
+ await module.onModuleInit();
+
+ expect(build).toHaveBeenCalledOnce();
+ expect(builder?.outDir).toBe(resolve(project, '.generated/workflow'));
+ const runtimeConfig = getRuntimeWorkflowConfig();
+ expect(runtimeConfig?.world).toBeTypeOf('function');
+ await expect(runtimeConfig?.world?.()).resolves.toMatchObject({
+ marker: 'world',
+ });
+
+ await module.onModuleDestroy();
+ expect(getRuntimeWorkflowConfig()).toBeUndefined();
+ });
+});
diff --git a/packages/nest/src/workflow.module.ts b/packages/nest/src/workflow.module.ts
index f6578fed68..56b6183d0e 100644
--- a/packages/nest/src/workflow.module.ts
+++ b/packages/nest/src/workflow.module.ts
@@ -1,11 +1,17 @@
import {
type DynamicModule,
+ Inject,
Module,
type OnModuleDestroy,
type OnModuleInit,
} from '@nestjs/common';
import { createBuildQueue } from '@workflow/builders';
-import { join } from 'pathe';
+import {
+ createRuntimeWorkflowConfig,
+ loadWorkflowConfig,
+} from '@workflow/config/load';
+import { setRuntimeWorkflowConfig } from '@workflow/config/runtime';
+import { closeWorld } from '@workflow/core/runtime';
import { type NestBuilderOptions, NestLocalBuilder } from './builder.js';
import {
configureWorkflowController,
@@ -20,17 +26,19 @@ export interface WorkflowModuleOptions extends NestBuilderOptions {
skipBuild?: boolean;
}
-const DEFAULT_OUT_DIR = '.nestjs/workflow';
-
/**
* NestJS module that provides workflow functionality.
* Builds workflow bundles on module initialization and registers the workflow controller.
*/
@Module({})
export class WorkflowModule implements OnModuleInit, OnModuleDestroy {
- private static builder: NestLocalBuilder | null = null;
private static buildQueue = createBuildQueue();
+ constructor(
+ @Inject('WORKFLOW_OPTIONS')
+ private readonly options: WorkflowModuleOptions
+ ) {}
+
/**
* Configure the WorkflowModule with options.
* Call this in your AppModule imports.
@@ -44,20 +52,6 @@ export class WorkflowModule implements OnModuleInit, OnModuleDestroy {
* ```
*/
static forRoot(options: WorkflowModuleOptions = {}): DynamicModule {
- const workingDir = options.workingDir ?? process.cwd();
- const outDir = options.outDir ?? join(workingDir, DEFAULT_OUT_DIR);
-
- // Configure the controller with the output directory
- configureWorkflowController(outDir);
-
- // Create builder if we're not skipping builds
- if (!options.skipBuild) {
- WorkflowModule.builder = new NestLocalBuilder({
- ...options,
- outDir,
- });
- }
-
return {
module: WorkflowModule,
controllers: [WorkflowController],
@@ -72,14 +66,37 @@ export class WorkflowModule implements OnModuleInit, OnModuleDestroy {
}
async onModuleInit() {
- const builder = WorkflowModule.builder;
- if (builder) {
- await WorkflowModule.buildQueue(() => builder.build());
- }
+ const { workingDir = process.cwd() } = this.options;
+ const workflowConfig = await loadWorkflowConfig({
+ cwd: workingDir,
+ integration: 'nest',
+ });
+ const config = workflowConfig.config;
+ const integration =
+ config.integration?.type === 'nest' ? config.integration : undefined;
+ const builder = new NestLocalBuilder({
+ ...this.options,
+ workflowConfig,
+ });
+
+ setRuntimeWorkflowConfig(
+ workflowConfig.path
+ ? createRuntimeWorkflowConfig(workflowConfig)
+ : undefined
+ );
+
+ const publicManifest =
+ process.env.WORKFLOW_PUBLIC_MANIFEST === undefined
+ ? (config.build?.manifest?.public ?? false)
+ : process.env.WORKFLOW_PUBLIC_MANIFEST === '1';
+ configureWorkflowController(builder.outDir, publicManifest);
+ if (this.options.skipBuild ?? integration?.skipBuild) return;
+
+ await WorkflowModule.buildQueue(() => builder.build());
}
async onModuleDestroy() {
- // Cleanup if needed
- WorkflowModule.builder = null;
+ await closeWorld();
+ setRuntimeWorkflowConfig(undefined);
}
}
diff --git a/packages/next/package.json b/packages/next/package.json
index 811b7cf4c0..b86cb47102 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -36,6 +36,7 @@
"dependencies": {
"@swc/core": "catalog:",
"@workflow/builders": "workspace:*",
+ "@workflow/config": "workspace:*",
"@workflow/core": "workspace:*",
"@workflow/swc-plugin": "workspace:*",
"semver": "catalog:",
diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts
index 005dd2897a..da8a16cbe3 100644
--- a/packages/next/src/builder-eager.ts
+++ b/packages/next/src/builder-eager.ts
@@ -20,7 +20,7 @@ export async function getNextBuilderEager() {
const {
BaseBuilder: BaseBuilderClass,
- WORKFLOW_QUEUE_TRIGGER,
+ createWorkflowQueueTrigger,
// biome-ignore lint/security/noGlobalEval: Need to use eval here to avoid TypeScript from transpiling the import statement into `require()`
} = (await eval(
'import("@workflow/builders")'
@@ -395,6 +395,8 @@ export async function getNextBuilderEager() {
protected async getInputFiles(): Promise {
const inputFiles = await super.getInputFiles();
+ if (this.config.workflowConfig?.config.build?.dirs) return inputFiles;
+
return inputFiles.filter((file) => {
const entry = relative(this.config.workingDir, file).replaceAll(
'\\',
@@ -435,7 +437,9 @@ export async function getNextBuilderEager() {
version: '0',
workflows: {
maxDuration: 'max',
- experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER],
+ experimentalTriggers: [
+ createWorkflowQueueTrigger({ namespace: this.queueNamespace }),
+ ],
},
};
diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts
index 6996e331b3..bac1481976 100644
--- a/packages/next/src/index.test.ts
+++ b/packages/next/src/index.test.ts
@@ -7,7 +7,7 @@ import {
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
-import { dirname, join } from 'node:path';
+import { dirname, join, relative } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
@@ -61,6 +61,7 @@ describe('withWorkflow builder config', () => {
const originalEnv = {
PORT: process.env.PORT,
VERCEL_DEPLOYMENT_ID: process.env.VERCEL_DEPLOYMENT_ID,
+ WORKFLOW_LOCAL_BASE_URL: process.env.WORKFLOW_LOCAL_BASE_URL,
WORKFLOW_LOCAL_DATA_DIR: process.env.WORKFLOW_LOCAL_DATA_DIR,
WORKFLOW_NEXT_PRIVATE_BUILT: process.env.WORKFLOW_NEXT_PRIVATE_BUILT,
WORKFLOW_TARGET_WORLD: process.env.WORKFLOW_TARGET_WORLD,
@@ -78,6 +79,7 @@ describe('withWorkflow builder config', () => {
delete process.env.PORT;
delete process.env.VERCEL_DEPLOYMENT_ID;
+ delete process.env.WORKFLOW_LOCAL_BASE_URL;
delete process.env.WORKFLOW_LOCAL_DATA_DIR;
delete process.env.WORKFLOW_NEXT_PRIVATE_BUILT;
delete process.env.WORKFLOW_TARGET_WORLD;
@@ -219,6 +221,96 @@ describe('withWorkflow builder config', () => {
expect(webpackConfig?.externals).toEqual([{ react: 'commonjs react' }]);
});
+ it('applies workflow.config.ts to the Next builder and runtime binding', async () => {
+ const projectDir = mkdtempSync(join(realTmpDir, 'workflow-next-config-'));
+ process.chdir(projectDir);
+ writeFile(
+ join(projectDir, 'workflow.world.ts'),
+ `export default () => {
+ throw new Error('World provider must not run during builds');
+};`
+ );
+ writeFile(
+ join(projectDir, 'workflow.config.ts'),
+ `export default {
+ world: './workflow.world.ts',
+ build: {
+ dirs: ['jobs'],
+ projectRoot: '../repo-root',
+ externalPackages: ['configured-external'],
+ sourcemap: false,
+ manifest: { public: true, output: 'custom-manifest.json' }
+ },
+ queue: { namespace: 'myapp' },
+ integration: {
+ type: 'next',
+ local: { port: 4321 }
+ }
+};`
+ );
+ process.env.PORT = '9876';
+ process.env.WORKFLOW_LOCAL_BASE_URL = 'http://localhost:9876';
+ let observedBaseUrl: string | undefined;
+
+ try {
+ const turbopackRoot = dirname(projectDir);
+ const config = withWorkflow(
+ async () => {
+ observedBaseUrl = process.env.WORKFLOW_LOCAL_BASE_URL;
+ return {
+ outputFileTracingRoot: '/explicit-root',
+ turbopack: { root: turbopackRoot },
+ };
+ },
+ { workflows: { local: { port: 4000 } } }
+ );
+ const resolvedConfig = await config('phase-production-build', {
+ defaultConfig: {},
+ });
+
+ expect(process.env.PORT).toBe('4000');
+ expect(observedBaseUrl).toBe('http://localhost:4000');
+ expect(process.env.WORKFLOW_TARGET_WORLD).toBeUndefined();
+ expect(builderConfigs[0]).toMatchObject({
+ dirs: ['jobs'],
+ projectRoot: '/explicit-root',
+ workflowConfig: {
+ path: join(projectDir, 'workflow.config.ts'),
+ runtimePath: join(
+ projectDir,
+ 'node_modules/.cache/workflow/runtime-config.mjs'
+ ),
+ config: {
+ world: './workflow.world.ts',
+ build: {
+ sourcemap: false,
+ manifest: {
+ public: true,
+ output: 'custom-manifest.json',
+ },
+ },
+ queue: { namespace: 'myapp' },
+ },
+ },
+ });
+ expect(builderConfigs[0]?.externalPackages).toContain(
+ 'configured-external'
+ );
+ expect(
+ (resolvedConfig.turbopack?.resolveAlias as Record)[
+ '@workflow/config/runtime-binding'
+ ]
+ ).toBe(
+ `./${relative(
+ turbopackRoot,
+ join(projectDir, 'node_modules/.cache/workflow/runtime-config.mjs')
+ )}`
+ );
+ } finally {
+ process.chdir(originalCwd);
+ rmSync(projectDir, { recursive: true, force: true });
+ }
+ });
it('removes workflow packages from serverExternalPackages for this build', async () => {
const projectDir = mkdtempSync(
join(realTmpDir, 'workflow-next-server-external-')
diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts
index 18262b2c51..96bc09168c 100644
--- a/packages/next/src/index.ts
+++ b/packages/next/src/index.ts
@@ -1,6 +1,7 @@
import { copyFileSync, mkdirSync, statSync } from 'node:fs';
import { copyFile, mkdir, readFile } from 'node:fs/promises';
-import { dirname, isAbsolute, join } from 'node:path';
+import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
+import type { SourcemapMode, WorkflowConfigLoader } from '@workflow/config';
import type { NextConfig } from 'next';
import semver from 'semver';
import { getNextBuilder } from './builder.js';
@@ -331,25 +332,10 @@ export function withWorkflow(
* source maps. Can also be set via the `WORKFLOW_SOURCEMAP`
* environment variable.
*/
- sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both';
+ sourcemap?: SourcemapMode;
};
} = {}
) {
- if (!process.env.VERCEL_DEPLOYMENT_ID) {
- if (!process.env.WORKFLOW_TARGET_WORLD) {
- process.env.WORKFLOW_TARGET_WORLD = 'local';
- process.env.WORKFLOW_LOCAL_DATA_DIR = '.next/workflow-data';
- }
- const maybePort = workflows?.local?.port;
- if (maybePort) {
- process.env.PORT = maybePort.toString();
- }
- } else {
- if (!process.env.WORKFLOW_TARGET_WORLD) {
- process.env.WORKFLOW_TARGET_WORLD = 'vercel';
- }
- }
-
return async function buildConfig(
phase: string,
ctx: { defaultConfig: NextConfig }
@@ -366,18 +352,49 @@ export function withWorkflow(
}
const loaderPath = require.resolve('./loader');
- let nextConfig: NextConfig;
-
- if (typeof nextConfigOrFn === 'function') {
- nextConfig = await nextConfigOrFn(phase, ctx);
- } else {
- nextConfig = nextConfigOrFn;
+ const { loadWorkflowConfig } = require('@workflow/config/load') as {
+ loadWorkflowConfig: WorkflowConfigLoader;
+ };
+ const loadedWorkflowConfig = await loadWorkflowConfig({
+ cwd: process.cwd(),
+ integration: 'next',
+ });
+ const workflowConfig = loadedWorkflowConfig.config;
+ const runtimeConfigPath = loadedWorkflowConfig.runtimePath;
+ const nextIntegration =
+ workflowConfig.integration?.type === 'next'
+ ? workflowConfig.integration
+ : undefined;
+
+ if (!process.env.VERCEL_DEPLOYMENT_ID) {
+ if (!workflowConfig.world && !process.env.WORKFLOW_TARGET_WORLD) {
+ process.env.WORKFLOW_TARGET_WORLD = 'local';
+ process.env.WORKFLOW_LOCAL_DATA_DIR = '.next/workflow-data';
+ }
+ if (workflows?.local?.port !== undefined) {
+ process.env.PORT = workflows.local.port.toString();
+ process.env.WORKFLOW_LOCAL_BASE_URL = `http://localhost:${workflows.local.port}`;
+ } else if (
+ process.env.PORT === undefined &&
+ nextIntegration?.local?.port !== undefined
+ ) {
+ process.env.PORT = nextIntegration.local.port.toString();
+ }
+ } else if (!workflowConfig.world && !process.env.WORKFLOW_TARGET_WORLD) {
+ process.env.WORKFLOW_TARGET_WORLD = 'vercel';
}
+
+ let nextConfig =
+ typeof nextConfigOrFn === 'function'
+ ? await nextConfigOrFn(phase, ctx)
+ : nextConfigOrFn;
// shallow clone to avoid read-only on top-level
nextConfig = Object.assign({}, nextConfig);
+
nextConfig.serverExternalPackages = [
...new Set([
...(nextConfig.serverExternalPackages || []),
+ ...(workflowConfig.build?.externalPackages || []),
// Keep the Vercel world and its native-prone dependencies external so
// local builds do not try to parse @vercel/queue's keyring dependency
// tree.
@@ -441,6 +458,23 @@ export function withWorkflow(
if (!nextConfig.turbopack.rules) {
nextConfig.turbopack.rules = {};
}
+ if (runtimeConfigPath) {
+ const existingResolveAlias = isPlainObject(
+ nextConfig.turbopack.resolveAlias
+ )
+ ? nextConfig.turbopack.resolveAlias
+ : {};
+ const runtimeConfigRequest = relative(
+ nextConfig.turbopack.root ?? process.cwd(),
+ runtimeConfigPath
+ ).replaceAll('\\', '/');
+ nextConfig.turbopack.resolveAlias = {
+ ...existingResolveAlias,
+ '@workflow/config/runtime-binding': runtimeConfigRequest.startsWith('.')
+ ? runtimeConfigRequest
+ : `./${runtimeConfigRequest}`,
+ };
+ }
const existingRules = nextConfig.turbopack.rules as any;
const nextVersion = resolveNextVersion(process.cwd());
const supportsTurboCondition = semver.gte(nextVersion, 'v16.0.0');
@@ -466,15 +500,19 @@ export function withWorkflow(
const NextBuilder = await getNextBuilder(nextVersion);
return new NextBuilder({
watch: shouldWatch,
- // getInputFiles filters the project to Next.js entrypoints
- dirs: ['.'],
+ // getInputFiles filters the default project scan to Next.js entrypoints
+ dirs: workflowConfig.build?.dirs ?? ['.'],
pageExtensions: nextConfig.pageExtensions ?? [
'tsx',
'ts',
'jsx',
'js',
],
- projectRoot: nextConfig.outputFileTracingRoot,
+ projectRoot:
+ nextConfig.outputFileTracingRoot ??
+ (workflowConfig.build?.projectRoot
+ ? resolve(process.cwd(), workflowConfig.build.projectRoot)
+ : undefined),
moduleSpecifierRoot: process.cwd(),
workingDir: process.cwd(),
distDir,
@@ -484,6 +522,7 @@ export function withWorkflow(
stepsBundlePath: '', // not used in base
webhookBundlePath: '', // node used in base
sourcemap: workflows?.sourcemap,
+ workflowConfig: loadedWorkflowConfig,
externalPackages: [
// server-only and client-only are pseudo-packages handled by Next.js
// during its build process. We mark them as external to prevent esbuild
@@ -550,6 +589,25 @@ export function withWorkflow(
test: /.*\.(mjs|cjs|cts|ts|tsx|js|jsx)$/,
loader: loaderPath,
});
+ if (runtimeConfigPath) {
+ webpackConfig.resolve ||= {};
+ const aliases = webpackConfig.resolve.alias;
+ if (Array.isArray(aliases)) {
+ webpackConfig.resolve.alias = [
+ ...aliases,
+ {
+ name: '@workflow/config/runtime-binding',
+ alias: runtimeConfigPath,
+ onlyModule: true,
+ },
+ ];
+ } else {
+ webpackConfig.resolve.alias = {
+ ...(aliases || {}),
+ '@workflow/config/runtime-binding': runtimeConfigPath,
+ };
+ }
+ }
return existingWebpackModify
? (existingWebpackModify(...args) ?? webpackConfig)
diff --git a/packages/nitro/package.json b/packages/nitro/package.json
index 7e7f48f6b4..fedae737d8 100644
--- a/packages/nitro/package.json
+++ b/packages/nitro/package.json
@@ -28,6 +28,7 @@
"dependencies": {
"@swc/core": "catalog:",
"@workflow/builders": "workspace:*",
+ "@workflow/config": "workspace:*",
"@workflow/core": "workspace:*",
"@workflow/rollup": "workspace:*",
"@workflow/swc-plugin": "workspace:*",
diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts
index c55af801eb..74e985dcae 100644
--- a/packages/nitro/src/builders.ts
+++ b/packages/nitro/src/builders.ts
@@ -4,39 +4,47 @@ import {
createBaseBuilderConfig,
VercelBuildOutputAPIBuilder,
} from '@workflow/builders';
+import type { LoadedWorkflowConfig } from '@workflow/config/load';
import type { Nitro } from 'nitro/types';
-import { join } from 'pathe';
+import { join, resolve } from 'pathe';
-/**
- * Forward string entries from Nitro's `externals.external` config to the
- * workflow builder's esbuild `external` option. RegExp and function entries
- * are skipped since esbuild's `external` only supports literal strings.
- *
- * Note: `externals.external` is on Nitro v2's options shape — v3 dropped it
- * in favour of `noExternals`. Reading it through a v2-shaped view lets us
- * still pick it up on v2 setups; on v3 the chained optional access just
- * returns undefined.
- */
type NitroV2ExternalsOptions = { externals?: { external?: unknown[] } };
-function getNitroStringExternals(nitro: Nitro): string[] | undefined {
- const external = (nitro.options as NitroV2ExternalsOptions).externals
- ?.external;
- const strings = external?.filter(
- (entry): entry is string => typeof entry === 'string'
- );
- return strings && strings.length > 0 ? strings : undefined;
+
+function createNitroBuilderConfig(
+ nitro: Nitro,
+ loadedConfig: LoadedWorkflowConfig
+) {
+ const build = loadedConfig.config.build;
+ // Nitro v3 dropped `externals.external`, so this v2-shaped read is empty.
+ const nitroExternals =
+ (nitro.options as NitroV2ExternalsOptions).externals?.external ?? [];
+ const externalPackages = [
+ ...new Set([
+ ...(build?.externalPackages ?? []),
+ ...nitroExternals.filter(
+ (entry): entry is string => typeof entry === 'string'
+ ),
+ ]),
+ ];
+
+ return createBaseBuilderConfig({
+ workingDir: nitro.options.rootDir,
+ dirs: nitro.options.workflow?.dirs ?? build?.dirs ?? ['.'],
+ projectRoot: build?.projectRoot
+ ? resolve(nitro.options.rootDir, build.projectRoot)
+ : undefined,
+ sourcemap: nitro.options.workflow?.sourcemap,
+ externalPackages:
+ externalPackages.length > 0 ? externalPackages : undefined,
+ workflowConfig: loadedConfig,
+ });
}
export class VercelBuilder extends VercelBuildOutputAPIBuilder {
- constructor(nitro: Nitro) {
+ constructor(nitro: Nitro, loadedConfig: LoadedWorkflowConfig) {
super({
- ...createBaseBuilderConfig({
- workingDir: nitro.options.rootDir,
- dirs: ['.'], // Different apps that use nitro have different directories
- runtime: nitro.options.workflow?.runtime,
- sourcemap: nitro.options.workflow?.sourcemap,
- externalPackages: getNitroStringExternals(nitro),
- }),
+ ...createNitroBuilderConfig(nitro, loadedConfig),
+ runtime: nitro.options.workflow?.runtime,
buildTarget: 'vercel-build-output-api',
});
}
@@ -55,16 +63,11 @@ export class VercelBuilder extends VercelBuildOutputAPIBuilder {
export class LocalBuilder extends BaseBuilder {
#outDir: string;
- constructor(nitro: Nitro) {
+ constructor(nitro: Nitro, loadedConfig: LoadedWorkflowConfig) {
const outDir = join(nitro.options.buildDir, 'workflow');
super({
- ...createBaseBuilderConfig({
- workingDir: nitro.options.rootDir,
- watch: nitro.options.dev,
- dirs: ['.'], // Different apps that use nitro have different directories
- sourcemap: nitro.options.workflow?.sourcemap,
- externalPackages: getNitroStringExternals(nitro),
- }),
+ ...createNitroBuilderConfig(nitro, loadedConfig),
+ watch: nitro.options.dev,
buildTarget: 'next', // Placeholder, not actually used
});
this.#outDir = outDir;
diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts
index ffb638fbad..c7f8211e6f 100644
--- a/packages/nitro/src/index.test.ts
+++ b/packages/nitro/src/index.test.ts
@@ -1,14 +1,41 @@
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
import { WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders';
-import { describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it } from 'vitest';
import { LocalBuilder, VercelBuilder } from './builders.js';
import nitroModule from './index.js';
+const projects: string[] = [];
+const originalEnv = {
+ WORKFLOW_QUEUE_NAMESPACE: process.env.WORKFLOW_QUEUE_NAMESPACE,
+ WORKFLOW_PUBLIC_MANIFEST: process.env.WORKFLOW_PUBLIC_MANIFEST,
+};
+
+function createProject(config: string): string {
+ const project = mkdtempSync(join(tmpdir(), 'workflow-nitro-config-'));
+ projects.push(project);
+ writeFileSync(join(project, 'workflow.config.ts'), config);
+ return project;
+}
+
+afterEach(() => {
+ for (const project of projects.splice(0)) {
+ rmSync(project, { recursive: true, force: true });
+ }
+ for (const [key, value] of Object.entries(originalEnv)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+});
+
type StubOptions = {
routing: boolean;
majorVersion?: number;
dev?: boolean;
preset?: string;
workflow?: { runtime?: string };
+ rootDir?: string;
externals?: {
external?: Array boolean)>;
};
@@ -21,6 +48,7 @@ function createNitroStub({
dev = false,
preset = 'node-server',
workflow = {},
+ rootDir = process.cwd(),
externals,
vercel,
}: StubOptions) {
@@ -34,7 +62,7 @@ function createNitroStub({
externals: externals ?? {},
handlers: [],
preset,
- rootDir: '/tmp/project',
+ rootDir,
typescript: {},
vercel: vercel ?? {},
virtual: {},
@@ -108,6 +136,117 @@ describe('@workflow/nitro virtual handlers', () => {
);
}
});
+
+ it('does not import config from unbundled dev routes without a config file', async () => {
+ const nitro = createNitroStub({ routing: false, dev: true });
+
+ await nitroModule.setup(nitro);
+
+ const source = nitro.options.virtual['#workflow/workflows.mjs'];
+ expect(source).not.toContain('@workflow/config');
+ });
+
+ it('installs runtime config before importing unbundled dev routes', async () => {
+ const project = createProject('export default {};');
+ const nitro = createNitroStub({
+ routing: false,
+ dev: true,
+ rootDir: project,
+ });
+
+ await nitroModule.setup(nitro);
+
+ const source = nitro.options.virtual['#workflow/workflows.mjs'];
+ const assignment =
+ 'globalThis[Symbol.for("@workflow/config/runtime")] = workflowConfig;';
+ expect(source).toContain(
+ 'import workflowConfig from "@workflow/config/runtime-binding";'
+ );
+ expect(source).toContain(assignment);
+ expect(source.indexOf(assignment)).toBeLessThan(
+ source.indexOf('import(currentImportPath)')
+ );
+ });
+});
+
+describe('@workflow/nitro workflow.config.ts', () => {
+ it('applies typed Nitro settings and a namespaced queue trigger', async () => {
+ const project = createProject(
+ `export default {
+ build: {
+ dirs: ['server/jobs'],
+ sourcemap: false,
+ manifest: { public: true }
+ },
+ queue: { namespace: 'myapp' },
+ integration: {
+ type: 'nitro',
+ typescriptPlugin: true,
+ runtime: 'nodejs24.x'
+ }
+};`
+ );
+ const nitro = createNitroStub({
+ routing: true,
+ preset: 'vercel',
+ rootDir: project,
+ });
+
+ await nitroModule.setup(nitro);
+
+ expect(nitro.options.workflow).toMatchObject({
+ dirs: ['server/jobs'],
+ typescriptPlugin: true,
+ runtime: 'nodejs24.x',
+ });
+ expect(
+ nitro.options.typescript.tsConfig.compilerOptions.plugins
+ ).toContainEqual({ name: 'workflow' });
+ expect(
+ nitro.options.vercel.functionRules['/.well-known/workflow/v1/flow']
+ .experimentalTriggers
+ ).toEqual([
+ expect.objectContaining({
+ type: 'queue/v2beta',
+ topic: '__myapp_wkf_workflow_*',
+ }),
+ ]);
+ expect(
+ nitro.options.handlers.some(
+ (handler: { route: string }) =>
+ handler.route === '/.well-known/workflow/v1/manifest.json'
+ )
+ ).toBe(true);
+ });
+
+ it('prefers environment variables over workflow.config.ts', async () => {
+ const project = createProject(
+ `export default {
+ build: { manifest: { public: true } },
+ queue: { namespace: 'configured' }
+};`
+ );
+ process.env.WORKFLOW_QUEUE_NAMESPACE = 'environment';
+ process.env.WORKFLOW_PUBLIC_MANIFEST = '0';
+ const nitro = createNitroStub({
+ routing: true,
+ preset: 'vercel',
+ rootDir: project,
+ });
+
+ await nitroModule.setup(nitro);
+
+ expect(
+ nitro.options.vercel.functionRules['/.well-known/workflow/v1/flow']
+ .experimentalTriggers[0].topic
+ ).toBe('__environment_wkf_workflow_*');
+ expect(
+ nitro.options.handlers.some(
+ (handler: { route: string }) =>
+ handler.route === '/.well-known/workflow/v1/manifest.json'
+ )
+ ).toBe(false);
+ });
});
describe('@workflow/nitro Vercel functionRules', () => {
@@ -309,6 +448,12 @@ describe('@workflow/nitro isNitroV2 detection', () => {
});
describe('@workflow/nitro externals forwarding', () => {
+ const loadedConfig = {
+ path: undefined,
+ runtimePath: undefined,
+ config: {},
+ } as const;
+
for (const [label, Builder] of [
['VercelBuilder', VercelBuilder],
['LocalBuilder', LocalBuilder],
@@ -316,7 +461,7 @@ describe('@workflow/nitro externals forwarding', () => {
describe(label, () => {
it('leaves externalPackages undefined when nitro externals are empty', () => {
const nitro = createNitroStub({ routing: true });
- const builder = new Builder(nitro) as any;
+ const builder = new Builder(nitro, loadedConfig) as any;
expect(builder.config.externalPackages).toBeUndefined();
});
@@ -325,7 +470,7 @@ describe('@workflow/nitro externals forwarding', () => {
routing: true,
externals: { external: ['fsevents', 'pg'] },
});
- const builder = new Builder(nitro) as any;
+ const builder = new Builder(nitro, loadedConfig) as any;
expect(builder.config.externalPackages).toEqual(['fsevents', 'pg']);
});
@@ -336,7 +481,7 @@ describe('@workflow/nitro externals forwarding', () => {
external: [/pkg/, () => true, 'fsevents'],
},
});
- const builder = new Builder(nitro) as any;
+ const builder = new Builder(nitro, loadedConfig) as any;
expect(builder.config.externalPackages).toEqual(['fsevents']);
});
@@ -345,7 +490,7 @@ describe('@workflow/nitro externals forwarding', () => {
routing: true,
externals: { external: [/pkg/, () => true] },
});
- const builder = new Builder(nitro) as any;
+ const builder = new Builder(nitro, loadedConfig) as any;
expect(builder.config.externalPackages).toBeUndefined();
});
});
diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts
index 3d2989bf46..bd623ffe33 100644
--- a/packages/nitro/src/index.ts
+++ b/packages/nitro/src/index.ts
@@ -1,7 +1,8 @@
-import { createRequire } from 'node:module';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders';
+import { createWorkflowQueueTrigger } from '@workflow/builders';
+import { loadWorkflowConfig } from '@workflow/config/load';
import { workflowTransformPlugin } from '@workflow/rollup';
import type { Nitro, NitroModule, RollupConfig } from 'nitro/types';
import { join } from 'pathe';
@@ -26,9 +27,35 @@ function isNitroV2(nitro: Nitro): boolean {
return !nitro.routing;
}
-export default {
+export const nitroModule = {
name: 'workflow/nitro',
- async setup(nitro: Nitro) {
+ async setup(nitro: Nitro): Promise {
+ const loadedWorkflowConfig = await loadWorkflowConfig({
+ cwd: nitro.options.rootDir,
+ integration: 'nitro',
+ });
+ const workflowConfig = loadedWorkflowConfig.config;
+ const runtimeConfigPath = loadedWorkflowConfig.runtimePath;
+ const nitroIntegration =
+ workflowConfig.integration?.type === 'nitro'
+ ? workflowConfig.integration
+ : undefined;
+ nitro.options.workflow = {
+ ...nitro.options.workflow,
+ dirs: nitro.options.workflow?.dirs ?? workflowConfig.build?.dirs,
+ typescriptPlugin:
+ nitro.options.workflow?.typescriptPlugin ??
+ nitroIntegration?.typescriptPlugin,
+ runtime: nitro.options.workflow?.runtime ?? nitroIntegration?.runtime,
+ };
+ const publicManifest =
+ process.env.WORKFLOW_PUBLIC_MANIFEST === undefined
+ ? (workflowConfig.build?.manifest?.public ?? false)
+ : process.env.WORKFLOW_PUBLIC_MANIFEST === '1';
+ const workflowQueueTrigger = createWorkflowQueueTrigger({
+ namespace:
+ process.env.WORKFLOW_QUEUE_NAMESPACE ?? workflowConfig.queue?.namespace,
+ });
const isVercelDeploy =
!nitro.options.dev && nitro.options.preset === 'vercel';
@@ -38,7 +65,18 @@ export default {
// Add transform plugin at the BEGINNING to run before other transforms
// (especially before class property transforms that rename classes like _ClassName)
nitro.hooks.hook('rollup:before', (_nitro: Nitro, config: RollupConfig) => {
- (config.plugins as Array).unshift(
+ const plugins: unknown[] = [];
+ if (runtimeConfigPath) {
+ plugins.push({
+ name: 'workflow:runtime-config',
+ resolveId(source: string) {
+ return source === '@workflow/config/runtime-binding'
+ ? runtimeConfigPath
+ : null;
+ },
+ });
+ }
+ plugins.push(
workflowTransformPlugin({
// Exclude pre-built workflow bundles from re-transformation
// These are already processed and re-processing causes issues like
@@ -46,11 +84,12 @@ export default {
exclude: [workflowBuildDir],
})
);
+ (config.plugins as Array).unshift(...plugins);
});
// NOTE: Temporary workaround for debug unenv mock
if (!nitro.options.workflow?._vite) {
- nitro.options.alias['debug'] ??= 'debug';
+ nitro.options.alias.debug ??= 'debug';
}
if (nitro.options.dev) {
@@ -163,7 +202,7 @@ export default {
if (useLegacyVercelBuild) {
nitro.hooks.hook('compiled', async () => {
- await new VercelBuilder(nitro).build();
+ await new VercelBuilder(nitro, loadedWorkflowConfig).build();
});
}
@@ -174,19 +213,16 @@ export default {
// vercel preset. This lets workflow handlers use nitro features
// (storage, database, runtime config, virtual imports, etc.).
if (!useLegacyVercelBuild) {
- const builder = new LocalBuilder(nitro);
+ const localBuilder = new LocalBuilder(nitro, loadedWorkflowConfig);
let isInitialBuild = true;
nitro.hooks.hook('build:before', async () => {
- await builder.build();
+ await localBuilder.build();
// For prod: write the manifest handler file with inlined content
// now that the builder has generated the manifest. Rollup will
// bundle this file into the compiled output.
- if (
- !nitro.options.dev &&
- process.env.WORKFLOW_PUBLIC_MANIFEST === '1'
- ) {
+ if (!nitro.options.dev && publicManifest) {
writeManifestHandler(nitro);
}
});
@@ -199,7 +235,7 @@ export default {
return;
}
try {
- await builder.build();
+ await localBuilder.build();
} catch (error) {
// During dev, files may be added/removed while the builder
// is rebuilding (e.g., during test cleanup). Log the error
@@ -217,7 +253,8 @@ export default {
addVirtualHandler(
nitro,
'/.well-known/workflow/v1/webhook/:token',
- 'workflow/webhook.mjs'
+ 'workflow/webhook.mjs',
+ runtimeConfigPath !== undefined
);
// V2: single combined handler for both workflow and step execution.
@@ -226,7 +263,8 @@ export default {
addVirtualHandler(
nitro,
'/.well-known/workflow/v1/flow',
- 'workflow/workflows.mjs'
+ 'workflow/workflows.mjs',
+ runtimeConfigPath !== undefined
);
// Nitro v3+ Vercel deploy: configure function rules for the combined
@@ -256,14 +294,14 @@ export default {
// V2 combined: a single trigger covers both `__wkf_workflow_*`
// (workflow orchestration) and `__wkf_step_*` (step execution),
// since the same handler dispatches both.
- experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER],
+ experimentalTriggers: [workflowQueueTrigger],
};
if (runtime) {
const webhookPath = '/.well-known/workflow/v1/webhook/:token';
rules[webhookPath] = { ...rules[webhookPath], runtime };
- if (process.env.WORKFLOW_PUBLIC_MANIFEST === '1') {
+ if (publicManifest) {
const manifestPath = '/.well-known/workflow/v1/manifest.json';
rules[manifestPath] = { ...rules[manifestPath], runtime };
}
@@ -271,7 +309,7 @@ export default {
}
// Expose manifest as a public HTTP route when WORKFLOW_PUBLIC_MANIFEST=1
- if (process.env.WORKFLOW_PUBLIC_MANIFEST === '1') {
+ if (publicManifest) {
// Write a placeholder manifest-data.mjs so rollup can resolve the
// import. It will be overwritten with the real manifest in build:before.
// Write a placeholder handler file so rollup can resolve the path
@@ -288,8 +326,17 @@ export default {
}
addManifestHandler(nitro);
}
+
+ return localBuilder;
}
},
+};
+
+export default {
+ name: nitroModule.name,
+ async setup(nitro: Nitro) {
+ await nitroModule.setup(nitro);
+ },
} satisfies NitroModule;
const DASHBOARD_VIRTUAL_ID = '#workflow/dashboard-handler';
@@ -360,7 +407,12 @@ function addDashboardHandler(nitro: Nitro) {
}
}
-function addVirtualHandler(nitro: Nitro, route: string, buildPath: string) {
+function addVirtualHandler(
+ nitro: Nitro,
+ route: string,
+ buildPath: string,
+ hasRuntimeConfig: boolean
+) {
nitro.options.handlers.push({
route,
handler: `#${buildPath}`,
@@ -370,6 +422,13 @@ function addVirtualHandler(nitro: Nitro, route: string, buildPath: string) {
);
if (nitro.options.dev) {
+ const runtimeConfigSetup = hasRuntimeConfig
+ ? `
+ import workflowConfig from "@workflow/config/runtime-binding";
+ globalThis[Symbol.for("@workflow/config/runtime")] = workflowConfig;
+ `
+ : '';
+
// Dev mode: load generated workflow bundles from disk at request time.
// This keeps `.nitro/workflow/*.mjs` out of Nitro's own bundle graph,
// which avoids rebuild loops and stale dependency graphs during HMR.
@@ -379,6 +438,7 @@ function addVirtualHandler(nitro: Nitro, route: string, buildPath: string) {
import { fromWebHandler } from "h3";
import { statSync } from "node:fs";
import { pathToFileURL } from "node:url";
+ ${runtimeConfigSetup}
const handlerPath = ${handlerImportPath};
let currentVersion = "";
@@ -402,6 +462,7 @@ function addVirtualHandler(nitro: Nitro, route: string, buildPath: string) {
nitro.options.virtual[`#${buildPath}`] = /* js */ `
import { statSync } from "node:fs";
import { pathToFileURL } from "node:url";
+ ${runtimeConfigSetup}
const handlerPath = ${handlerImportPath};
let currentVersion = "";
diff --git a/packages/nitro/src/types.ts b/packages/nitro/src/types.ts
index 274d311a95..d01e19cbda 100644
--- a/packages/nitro/src/types.ts
+++ b/packages/nitro/src/types.ts
@@ -1,3 +1,5 @@
+import type { SourcemapMode } from '@workflow/config';
+
export interface ModuleOptions {
/** @internal */
_vite?: boolean;
@@ -34,7 +36,7 @@ export interface ModuleOptions {
*
* Can also be set via the `WORKFLOW_SOURCEMAP` environment variable.
*/
- sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both';
+ sourcemap?: SourcemapMode;
}
declare module 'nitro/types' {
diff --git a/packages/nitro/src/vite.ts b/packages/nitro/src/vite.ts
index d80af7da80..d4b954434f 100644
--- a/packages/nitro/src/vite.ts
+++ b/packages/nitro/src/vite.ts
@@ -5,12 +5,12 @@ import type { Nitro } from 'nitro/types';
import type {} from 'nitro/vite';
import { join } from 'pathe';
import type { Plugin } from 'vite';
-import { LocalBuilder } from './builders.js';
+import type { LocalBuilder } from './builders.js';
import type { ModuleOptions } from './index.js';
-import nitroModule from './index.js';
+import { nitroModule } from './index.js';
export function workflow(options?: ModuleOptions): Plugin[] {
- let builder: LocalBuilder;
+ let builder: LocalBuilder | undefined;
let workflowBuildDir: string;
const enqueue = createBuildQueue();
@@ -33,7 +33,7 @@ export function workflow(options?: ModuleOptions): Plugin[] {
{
name: 'workflow:nitro',
nitro: {
- setup: (nitro: Nitro) => {
+ setup: async (nitro: Nitro) => {
// Capture the workflow build directory for exclusion
workflowBuildDir = join(nitro.options.buildDir, 'workflow');
nitro.options.workflow = {
@@ -41,10 +41,7 @@ export function workflow(options?: ModuleOptions): Plugin[] {
...options,
_vite: true,
};
- if (nitro.options.dev) {
- builder = new LocalBuilder(nitro);
- }
- return nitroModule.setup(nitro);
+ builder = await nitroModule.setup(nitro);
},
},
// NOTE: This is a workaround because Nitro passes the 404 requests to the dev server to handle.
diff --git a/packages/workflow/package.json b/packages/workflow/package.json
index af4b184d16..50a8867c84 100644
--- a/packages/workflow/package.json
+++ b/packages/workflow/package.json
@@ -38,6 +38,10 @@
"workflow": "./dist/api-workflow.js",
"default": "./dist/api.js"
},
+ "./config": {
+ "types": "./dist/config.d.ts",
+ "default": "./dist/config.js"
+ },
"./errors": "./dist/internal/errors.js",
"./internal/errors": "./dist/internal/errors.js",
"./internal/builtins": "./dist/internal/builtins.js",
@@ -67,6 +71,7 @@
"dependencies": {
"@workflow/astro": "workspace:*",
"@workflow/cli": "workspace:*",
+ "@workflow/config": "workspace:*",
"@workflow/core": "workspace:*",
"@workflow/errors": "workspace:*",
"@workflow/typescript-plugin": "workspace:*",
diff --git a/packages/workflow/src/config.ts b/packages/workflow/src/config.ts
new file mode 100644
index 0000000000..e3a851eb14
--- /dev/null
+++ b/packages/workflow/src/config.ts
@@ -0,0 +1 @@
+export * from '@workflow/config';
diff --git a/packages/world-postgres/HOW_IT_WORKS.md b/packages/world-postgres/HOW_IT_WORKS.md
index 4c13a2a20e..4eb60af1a0 100644
--- a/packages/world-postgres/HOW_IT_WORKS.md
+++ b/packages/world-postgres/HOW_IT_WORKS.md
@@ -33,23 +33,25 @@ Real-time data streaming via **PostgreSQL LISTEN/NOTIFY**:
## Setup
-Call `world.start()` to initialize graphile-worker workers. When `.start()` is called, workers begin listening to graphile-worker queues. When a job arrives, the worker executes the queue message over the workflow HTTP routes and awaits completion before acknowledging the Graphile job.
+The Workflow runtime calls `world.start()` once for Worlds selected in
+`workflow.config.ts`. When constructing a World directly, call `world.start()`
+yourself. Workers then listen to graphile-worker queues and execute messages
+over the Workflow HTTP routes before acknowledging each job.
When the runtime returns `{ timeoutSeconds }`, the worker schedules a new Graphile job with a future `runAt` time before finishing the current task.
The worker targets the HTTP-compatible workflow endpoints directly: `.well-known/workflow/v1/flow` for workflows and `.well-known/workflow/v1/step` for steps.
-In **Next.js**, the `world.start()` call needs to be added to `instrumentation.ts|js` to ensure workers start before request handling. Use `workflow/runtime` for `getWorld` (same as the testing server and other framework plugins):
+In **Next.js**, eagerly call `getWorld()` from `instrumentation.ts|js` to start
+the configured provider before request handling:
```ts
// instrumentation.ts
if (process.env.NEXT_RUNTIME !== "edge") {
import("workflow/runtime").then(async ({ getWorld }) => {
- // start listening to the jobs.
- const world = await getWorld();
- await world.start?.();
+ await getWorld();
});
}
```
diff --git a/packages/world/README.md b/packages/world/README.md
index c5f28b767e..7e3898ac29 100644
--- a/packages/world/README.md
+++ b/packages/world/README.md
@@ -4,4 +4,5 @@ Core interfaces and types for Workflow SDK storage backends.
This package defines the `World` interface that abstracts workflow storage, queuing, authentication, and streaming operations. Implementation packages like `@workflow/world-local` and `@workflow/world-vercel` provide concrete implementations.
-Used internally by `@workflow/core` and world implementations. Should not be used directly in application code.
+World implementations and provider modules use the `World` and `WorldProvider`
+types exported here.
diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts
index a30dae2ab9..2ad395d687 100644
--- a/packages/world/src/interfaces.ts
+++ b/packages/world/src/interfaces.ts
@@ -364,3 +364,5 @@ export interface World extends Queue, Streamer, Storage {
context?: Record
): Promise;
}
+
+export type WorldProvider = () => World | Promise;
diff --git a/packages/world/src/queue.test.ts b/packages/world/src/queue.test.ts
index 6031d69633..6511fcaff8 100644
--- a/packages/world/src/queue.test.ts
+++ b/packages/world/src/queue.test.ts
@@ -1,12 +1,41 @@
-import { describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it } from 'vitest';
import {
getQueuePrefixKind,
getQueueTopicPrefix,
parseQueueName,
QueuePrefix,
+ resolveQueueNamespace,
+ setWorkflowQueueNamespace,
ValidQueueName,
} from './queue.js';
+const originalQueueNamespace = process.env.WORKFLOW_QUEUE_NAMESPACE;
+
+afterEach(() => {
+ setWorkflowQueueNamespace(undefined);
+ if (originalQueueNamespace === undefined) {
+ delete process.env.WORKFLOW_QUEUE_NAMESPACE;
+ } else {
+ process.env.WORKFLOW_QUEUE_NAMESPACE = originalQueueNamespace;
+ }
+});
+
+describe('resolveQueueNamespace', () => {
+ it('uses explicit, environment, config, then default precedence', () => {
+ setWorkflowQueueNamespace('configured');
+ process.env.WORKFLOW_QUEUE_NAMESPACE = 'environment';
+
+ expect(resolveQueueNamespace('explicit')).toBe('explicit');
+ expect(resolveQueueNamespace()).toBe('environment');
+
+ delete process.env.WORKFLOW_QUEUE_NAMESPACE;
+ expect(resolveQueueNamespace()).toBe('configured');
+
+ setWorkflowQueueNamespace(undefined);
+ expect(resolveQueueNamespace()).toBeUndefined();
+ });
+});
+
describe('getQueueTopicPrefix', () => {
it('returns default workflow prefix without namespace', () => {
expect(getQueueTopicPrefix('workflow')).toBe('__wkf_workflow_');
diff --git a/packages/world/src/queue.ts b/packages/world/src/queue.ts
index f60ae034a7..df99565ebd 100644
--- a/packages/world/src/queue.ts
+++ b/packages/world/src/queue.ts
@@ -25,19 +25,37 @@ export const ValidQueueName = z
);
export type ValidQueueName = z.infer;
-const QueueNamespace = z
+export const QueueNamespaceSchema = z
.string()
.regex(
/^[a-z][a-z0-9]*$/,
'Must be lowercase alphanumeric, starting with a letter'
);
+const WorkflowQueueNamespace = Symbol.for('@workflow/queue/namespace');
+
+const queueGlobals = globalThis as typeof globalThis & {
+ [WorkflowQueueNamespace]?: string;
+};
+
/**
- * Resolves the active queue namespace from an explicit argument or the
- * `WORKFLOW_QUEUE_NAMESPACE` env var.
+ * Sets the process-local queue namespace resolved from workflow.config.ts.
+ * Explicit function arguments and WORKFLOW_QUEUE_NAMESPACE take precedence.
+ */
+export function setWorkflowQueueNamespace(namespace: string | undefined): void {
+ queueGlobals[WorkflowQueueNamespace] = namespace;
+}
+
+/**
+ * Resolves the active queue namespace from an explicit argument, the loaded
+ * WORKFLOW_QUEUE_NAMESPACE env var, or the loaded Workflow config.
*/
export function resolveQueueNamespace(namespace?: string): string | undefined {
- return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE ?? undefined;
+ return (
+ namespace ??
+ process.env.WORKFLOW_QUEUE_NAMESPACE ??
+ queueGlobals[WorkflowQueueNamespace]
+ );
}
/**
@@ -51,7 +69,7 @@ export function getQueueTopicPrefix(
namespace?: string
): QueuePrefix {
if (namespace !== undefined) {
- QueueNamespace.parse(namespace);
+ QueueNamespaceSchema.parse(namespace);
return `__${namespace}_wkf_${kind}_` as QueuePrefix;
}
return `__wkf_${kind}_` as QueuePrefix;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f58a922fa3..6fd3bebe1f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -406,6 +406,9 @@ importers:
'@swc/core':
specifier: 'catalog:'
version: 1.15.3
+ '@workflow/config':
+ specifier: workspace:*
+ version: link:../config
'@workflow/core':
specifier: workspace:*
version: link:../core
@@ -418,6 +421,9 @@ importers:
'@workflow/utils':
specifier: workspace:*
version: link:../utils
+ '@workflow/world':
+ specifier: workspace:*
+ version: link:../world
builtin-modules:
specifier: 5.0.0
version: 5.0.0
@@ -464,6 +470,9 @@ importers:
'@workflow/builders':
specifier: workspace:*
version: link:../builders
+ '@workflow/config':
+ specifier: workspace:*
+ version: link:../config
'@workflow/core':
specifier: workspace:*
version: link:../core
@@ -547,6 +556,31 @@ importers:
specifier: workspace:*
version: link:../tsconfig
+ packages/config:
+ dependencies:
+ '@workflow/world':
+ specifier: workspace:*
+ version: link:../world
+ find-up:
+ specifier: 7.0.0
+ version: 7.0.0
+ jiti:
+ specifier: 2.7.0
+ version: 2.7.0
+ zod:
+ specifier: 'catalog:'
+ version: 4.3.6
+ devDependencies:
+ '@types/node':
+ specifier: 'catalog:'
+ version: 22.19.0
+ '@workflow/tsconfig':
+ specifier: workspace:*
+ version: link:../tsconfig
+ vitest:
+ specifier: 'catalog:'
+ version: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)
+
packages/core:
dependencies:
'@aws-sdk/credential-provider-web-identity':
@@ -564,6 +598,9 @@ importers:
'@vercel/functions':
specifier: 'catalog:'
version: 3.4.3(@aws-sdk/credential-provider-web-identity@3.972.49)
+ '@workflow/config':
+ specifier: workspace:*
+ version: link:../config
'@workflow/errors':
specifier: workspace:*
version: link:../errors
@@ -726,6 +763,12 @@ importers:
'@workflow/builders':
specifier: workspace:*
version: link:../builders
+ '@workflow/config':
+ specifier: workspace:*
+ version: link:../config
+ '@workflow/core':
+ specifier: workspace:*
+ version: link:../core
'@workflow/swc-plugin':
specifier: workspace:*
version: link:../swc-plugin-workflow
@@ -760,6 +803,9 @@ importers:
'@workflow/builders':
specifier: workspace:*
version: link:../builders
+ '@workflow/config':
+ specifier: workspace:*
+ version: link:../config
'@workflow/core':
specifier: workspace:*
version: link:../core
@@ -800,6 +846,9 @@ importers:
'@workflow/builders':
specifier: workspace:*
version: link:../builders
+ '@workflow/config':
+ specifier: workspace:*
+ version: link:../config
'@workflow/core':
specifier: workspace:*
version: link:../core
@@ -1285,6 +1334,9 @@ importers:
'@workflow/cli':
specifier: workspace:*
version: link:../cli
+ '@workflow/config':
+ specifier: workspace:*
+ version: link:../config
'@workflow/core':
specifier: workspace:*
version: link:../core
diff --git a/scripts/stage-workbench-with-tarballs.mjs b/scripts/stage-workbench-with-tarballs.mjs
index 307a9ef3b5..b8b7e45b35 100644
--- a/scripts/stage-workbench-with-tarballs.mjs
+++ b/scripts/stage-workbench-with-tarballs.mjs
@@ -1,3 +1,4 @@
+import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
@@ -248,32 +249,6 @@ function rewriteDependencySpecs(
return { replacedWithTarballs, replacedCatalogEntries };
}
-function applyTarballOverrides(packageJsonPath, tarballPathByPackageName) {
- const packageJson = readJson(packageJsonPath);
- const pnpmConfig =
- packageJson.pnpm && typeof packageJson.pnpm === 'object'
- ? packageJson.pnpm
- : {};
- const overrides =
- pnpmConfig.overrides && typeof pnpmConfig.overrides === 'object'
- ? pnpmConfig.overrides
- : {};
-
- let overridesApplied = 0;
- for (const [packageName, tarballPath] of tarballPathByPackageName.entries()) {
- overrides[packageName] = `file:${tarballPath}`;
- overridesApplied += 1;
- }
-
- packageJson.pnpm = {
- ...pnpmConfig,
- overrides,
- };
-
- writeJson(packageJsonPath, packageJson);
- return overridesApplied;
-}
-
function main() {
const args = process.argv.slice(2).filter((arg) => arg !== '--');
const [workbenchArg] = args;
@@ -351,16 +326,31 @@ function main() {
tarballPathByPackageName,
catalog
);
- const overridesApplied = applyTarballOverrides(
- stagedPackageJsonPath,
- tarballPathByPackageName
+
+ const packageJson = readJson(stagedPackageJsonPath);
+ const { packageManager } = readJson(path.join(repoRoot, 'package.json'));
+ assert(typeof packageManager === 'string');
+ packageJson.packageManager = packageManager;
+ writeJson(stagedPackageJsonPath, packageJson);
+
+ fs.writeFileSync(
+ path.join(stagedWorkbenchDir, 'pnpm-workspace.yaml'),
+ [
+ 'overrides:',
+ ...Array.from(
+ tarballPathByPackageName,
+ ([packageName, tarballPath]) =>
+ ` ${JSON.stringify(packageName)}: ${JSON.stringify(`file:${tarballPath}`)}`
+ ),
+ '',
+ ].join('\n')
);
console.log(
`Rewrote ${replacedWithTarballs.length} monorepo dependencies to tarballs and ${replacedCatalogEntries.length} catalog dependencies to versions`
);
console.log(
- `Applied ${overridesApplied} pnpm tarball overrides for transitive monorepo packages`
+ `Applied ${tarballPathByPackageName.size} pnpm tarball overrides for transitive monorepo packages`
);
console.log(`Installing dependencies in ${stagedWorkbenchDir}`);