From d111d8e71b080c26a68dbe8cd3b8dd99d11a17e2 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:35:14 -0700 Subject: [PATCH 01/15] Add typed workflow configuration Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .changeset/framework-shared-config.md | 7 + .changeset/shared-config-runtime.md | 8 + .changeset/typed-world-providers.md | 9 + .../workflow-next/with-workflow.mdx | 11 +- .../v5/api-reference/workflow-nitro/index.mdx | 4 + .../docs/v5/foundations/configuration.mdx | 108 ++++++++++++ docs/content/docs/v5/foundations/meta.json | 1 + packages/builders/package.json | 2 + packages/builders/src/base-builder.ts | 53 ++++-- packages/builders/src/config-helpers.ts | 3 + packages/builders/src/constants.test.ts | 16 +- packages/builders/src/constants.ts | 44 +++-- .../builders/src/resolve-sourcemap.test.ts | 30 +++- .../builders/src/runtime-config-plugin.ts | 15 ++ packages/builders/src/types.ts | 19 +-- .../builders/src/vercel-build-output-api.ts | 4 +- packages/cli/package.json | 1 + packages/cli/src/base.ts | 5 +- packages/cli/src/commands/build.ts | 7 +- .../cli/src/lib/config/workflow-config.ts | 45 +++-- packages/cli/src/lib/inspect/env.ts | 4 +- packages/config/README.md | 27 +++ packages/config/package.json | 61 +++++++ packages/config/src/index.ts | 14 ++ packages/config/src/load.cts | 11 ++ packages/config/src/load.test.ts | 160 ++++++++++++++++++ packages/config/src/load.ts | 156 +++++++++++++++++ packages/config/src/runtime-binding.ts | 5 + packages/config/src/runtime.ts | 17 ++ packages/config/src/schema.ts | 62 +++++++ packages/config/tsconfig.json | 8 + packages/core/package.json | 1 + packages/core/src/runtime.ts | 1 + .../core/src/runtime/world-config.test.ts | 132 +++++++++++++++ packages/core/src/runtime/world.ts | 146 ++++++++++++---- packages/nest/package.json | 1 + packages/nest/src/builder.ts | 43 ++++- packages/nest/src/workflow.module.test.ts | 84 +++++++++ packages/nest/src/workflow.module.ts | 54 +++--- packages/next/README.md | 21 +++ packages/next/package.json | 1 + packages/next/src/builder-eager.ts | 6 +- packages/next/src/index.test.ts | 77 ++++++++- packages/next/src/index.ts | 118 ++++++++++--- packages/nitro/package.json | 1 + packages/nitro/src/builders.ts | 38 ++++- packages/nitro/src/index.test.ts | 77 ++++++++- packages/nitro/src/index.ts | 64 +++++-- packages/nitro/src/types.ts | 4 +- packages/nitro/src/vite.ts | 13 +- packages/workflow/package.json | 5 + packages/workflow/src/config.ts | 1 + packages/world-local/README.md | 15 ++ packages/world-local/src/index.ts | 18 +- packages/world-postgres/HOW_IT_WORKS.md | 20 ++- packages/world-postgres/README.md | 18 ++ packages/world-postgres/src/index.ts | 71 ++++++-- packages/world-vercel/README.md | 14 +- packages/world-vercel/src/index.ts | 37 +++- packages/world/README.md | 18 +- packages/world/src/index.ts | 8 + packages/world/src/provider.ts | 27 +++ packages/world/src/queue.ts | 28 ++- pnpm-lock.yaml | 49 +++++- 64 files changed, 1884 insertions(+), 244 deletions(-) create mode 100644 .changeset/framework-shared-config.md create mode 100644 .changeset/shared-config-runtime.md create mode 100644 .changeset/typed-world-providers.md create mode 100644 docs/content/docs/v5/foundations/configuration.mdx create mode 100644 packages/builders/src/runtime-config-plugin.ts create mode 100644 packages/config/README.md create mode 100644 packages/config/package.json create mode 100644 packages/config/src/index.ts create mode 100644 packages/config/src/load.cts create mode 100644 packages/config/src/load.test.ts create mode 100644 packages/config/src/load.ts create mode 100644 packages/config/src/runtime-binding.ts create mode 100644 packages/config/src/runtime.ts create mode 100644 packages/config/src/schema.ts create mode 100644 packages/config/tsconfig.json create mode 100644 packages/core/src/runtime/world-config.test.ts create mode 100644 packages/nest/src/workflow.module.test.ts create mode 100644 packages/workflow/src/config.ts create mode 100644 packages/world/src/provider.ts 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/shared-config-runtime.md b/.changeset/shared-config-runtime.md new file mode 100644 index 0000000000..b7e28737a9 --- /dev/null +++ b/.changeset/shared-config-runtime.md @@ -0,0 +1,8 @@ +--- +"@workflow/builders": minor +"@workflow/cli": minor +"@workflow/core": minor +"workflow": minor +--- + +Load shared Workflow configuration across runtime, build, and CLI entry points. diff --git a/.changeset/typed-world-providers.md b/.changeset/typed-world-providers.md new file mode 100644 index 0000000000..8eff911609 --- /dev/null +++ b/.changeset/typed-world-providers.md @@ -0,0 +1,9 @@ +--- +"@workflow/config": minor +"@workflow/world": minor +"@workflow/world-local": minor +"@workflow/world-postgres": minor +"@workflow/world-vercel": minor +--- + +Add typed World provider helpers and a shared Workflow configuration schema. 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..a0a2d8ccc5 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 @@ -82,7 +82,11 @@ 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. +Build and Next.js integration settings can be placed in +[`workflow.config.ts`](/docs/foundations/configuration). + +`withWorkflow` also accepts an optional second argument. Values passed there +take precedence over `workflow.config.ts`. ```typescript title="next.config.ts" lineNumbers import type { NextConfig } from "next"; @@ -123,7 +127,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. 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..590d9a9478 100644 --- a/docs/content/docs/v5/api-reference/workflow-nitro/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-nitro/index.mdx @@ -9,6 +9,10 @@ related: 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. +Generic build settings and Nitro-specific options may also be placed in +[`workflow.config.ts`](/docs/foundations/configuration). Explicit +`nitro.options.workflow` values take precedence. + ## Usage ```typescript title="nitro.config.ts" lineNumbers diff --git a/docs/content/docs/v5/foundations/configuration.mdx b/docs/content/docs/v5/foundations/configuration.mdx new file mode 100644 index 0000000000..0790332c17 --- /dev/null +++ b/docs/content/docs/v5/foundations/configuration.mdx @@ -0,0 +1,108 @@ +--- +title: Configuration +description: Configure Workflow SDK worlds, builds, queues, and host integrations in one typed file. +type: conceptual +summary: Use workflow.config.ts as the typed source of truth for Workflow SDK settings. +prerequisites: + - /docs/foundations/workflows-and-steps +related: + - /docs/deploying/world/local-world + - /docs/deploying/world/postgres-world + - /docs/api-reference/workflow-next/with-workflow +--- + +Use `workflow.config.ts` to configure Workflow SDK: + +```typescript title="workflow.config.ts" lineNumbers +import { defineConfig } from "workflow/config"; +import { postgresWorld } from "@workflow/world-postgres"; + +export default defineConfig({ + world: postgresWorld({ + connectionString: () => process.env.WORKFLOW_POSTGRES_URL!, + }), + build: { + dirs: ["workflows"], + sourcemap: false, + }, + integration: { + type: "next", + lazyDiscovery: true, + }, +}); +``` + + + The config file supplies settings; it does not activate a framework + integration. For example, Next.js projects must still wrap + `next.config.ts` with `withWorkflow()`. + + +## Config File + +Default-export a static object. `defineConfig()` provides type checking. In a +monorepo, place the file in the application directory; the nearest config is +used. + +## Precedence + +From highest to lowest priority: + +1. Explicit CLI flags or framework options +2. `workflow.config.ts` +3. Environment variables +4. Built-in defaults + +## World Providers + +The `world` field accepts a typed `WorldProvider`: + +```typescript +import { localWorld } from "@workflow/world-local"; +import { postgresWorld } from "@workflow/world-postgres"; +import { vercelWorld } from "@workflow/world-vercel"; +``` + +World packages can expose their own typed helpers: + +{/* @skip-typecheck: conceptual custom provider package example */} + +```typescript +import { defineWorldProvider } from "@workflow/world"; + +export function hybridWorld(options: HybridOptions) { + return defineWorldProvider({ + id: "@acme/workflow-world", + create: () => createHybridWorld(options), + }); +} +``` + +## Environment Values + +```typescript +import { postgresWorld } from "@workflow/world-postgres"; + +postgresWorld({ + connectionString: () => process.env.WORKFLOW_POSTGRES_URL!, +}); +``` + +Provider fields that accept a callback read its value when the World is +created. + +## Integration Settings + +`integration` is optional and only needed for integration-specific behavior. +It is a discriminated union, so one config cannot contain settings for +multiple integrations. + +{/* @skip-typecheck: mutually exclusive config fragments */} + +```typescript +// Next.js +integration: { type: "next", lazyDiscovery: true } + +// 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..41b526c31b 100644 --- a/docs/content/docs/v5/foundations/meta.json +++ b/docs/content/docs/v5/foundations/meta.json @@ -2,6 +2,7 @@ "title": "Foundations", "pages": [ "workflows-and-steps", + "configuration", "starting-workflows", "errors-and-retries", "hooks", 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..6ce707da88 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -34,6 +34,7 @@ import { } from './module-specifier.js'; import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js'; import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js'; +import { createRuntimeConfigPlugin } from './runtime-config-plugin.js'; import { createSwcPlugin } from './swc-esbuild-plugin.js'; import { detectWorkflowPatterns } from './transform-utils.js'; import type { SourcemapMode, WorkflowConfig } from './types.js'; @@ -228,6 +229,16 @@ export abstract class BaseBuilder { return this.config.moduleSpecifierRoot || this.transformProjectRoot; } + protected get queueNamespace(): string | undefined { + return this.config.workflowConfig?.config.queue?.namespace; + } + + private get runtimeConfigPlugins(): esbuild.Plugin[] { + const workflowConfig = this.config.workflowConfig; + if (!workflowConfig?.found) return []; + return [createRuntimeConfigPlugin(workflowConfig.path)]; + } + /** * Whether informational BaseBuilder logs should be printed. * Subclasses can override this to silence progress logs while keeping warnings/errors. @@ -1352,11 +1363,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 +1436,9 @@ export const __steps_registered = true; } } - const workflowEntrypointOptionsCode = - createWorkflowEntrypointOptionsCode(); + const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode( + this.queueNamespace + ); const bundleFinal = async (interimBundle: string) => { const workflowBundleCode = interimBundle; @@ -1480,6 +1492,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 +1628,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( + this.queueNamespace + ); const combinedFunctionCode = `// biome-ignore-all lint: generated file /* eslint-disable */ @@ -1661,6 +1676,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 +1701,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( + this.queueNamespace + ); const code = `// biome-ignore-all lint: generated file /* eslint-disable */ import { __steps_registered } from '${stepsRelativePath}'; @@ -1938,6 +1955,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 +2092,13 @@ export const OPTIONS = handler;`; /** * Whether the manifest should be exposed as a public HTTP route. - * Controlled by the `WORKFLOW_PUBLIC_MANIFEST` environment variable. + * workflow.config.ts takes precedence over WORKFLOW_PUBLIC_MANIFEST. */ protected get shouldExposePublicManifest(): boolean { - return process.env.WORKFLOW_PUBLIC_MANIFEST === '1'; + return ( + this.config.workflowConfig?.config.build?.manifest?.public ?? + process.env.WORKFLOW_PUBLIC_MANIFEST === '1' + ); } /** @@ -2126,12 +2147,14 @@ 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.config.ts > WORKFLOW_SOURCEMAP > 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 configMode = this.config.workflowConfig?.config.build?.sourcemap; + if (configMode !== undefined) return configMode; const envMode = parseSourcemapEnv(process.env.WORKFLOW_SOURCEMAP); if (envMode !== undefined) return envMode; 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/constants.test.ts b/packages/builders/src/constants.test.ts index 2474a7e1a5..fc8439814c 100644 --- a/packages/builders/src/constants.test.ts +++ b/packages/builders/src/constants.test.ts @@ -10,11 +10,13 @@ describe('createWorkflowQueueTrigger', () => { }); it('uses the default workflow topic without a namespace', () => { - expect(createWorkflowQueueTrigger().topic).toBe('__wkf_workflow_*'); + expect(createWorkflowQueueTrigger(undefined).topic).toBe( + '__wkf_workflow_*' + ); }); it('uses an explicit namespace when provided', () => { - expect(createWorkflowQueueTrigger({ namespace: 'custom' }).topic).toBe( + expect(createWorkflowQueueTrigger('custom').topic).toBe( '__custom_wkf_workflow_*' ); }); @@ -22,7 +24,9 @@ describe('createWorkflowQueueTrigger', () => { it('uses WORKFLOW_QUEUE_NAMESPACE when no explicit namespace is provided', () => { process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom'; - expect(createWorkflowQueueTrigger().topic).toBe('__custom_wkf_workflow_*'); + expect(createWorkflowQueueTrigger(undefined).topic).toBe( + '__custom_wkf_workflow_*' + ); }); }); @@ -32,11 +36,11 @@ describe('createWorkflowEntrypointOptionsCode', () => { }); it('omits runtime options without a namespace', () => { - expect(createWorkflowEntrypointOptionsCode()).toBe(''); + expect(createWorkflowEntrypointOptionsCode(undefined)).toBe(''); }); it('inlines an explicit namespace', () => { - expect(createWorkflowEntrypointOptionsCode({ namespace: 'custom' })).toBe( + expect(createWorkflowEntrypointOptionsCode('custom')).toBe( ', { namespace: "custom" }' ); }); @@ -44,7 +48,7 @@ describe('createWorkflowEntrypointOptionsCode', () => { it('inlines WORKFLOW_QUEUE_NAMESPACE at build time', () => { process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom'; - expect(createWorkflowEntrypointOptionsCode()).toBe( + expect(createWorkflowEntrypointOptionsCode(undefined)).toBe( ', { namespace: "custom" }' ); }); diff --git a/packages/builders/src/constants.ts b/packages/builders/src/constants.ts index 0f88e13e50..54ade35b93 100644 --- a/packages/builders/src/constants.ts +++ b/packages/builders/src/constants.ts @@ -1,17 +1,15 @@ -const QUEUE_NAMESPACE_PATTERN = /^[a-z][a-z0-9]*$/; +import { QueueNamespaceSchema } from '@workflow/world'; -function resolveQueueNamespace(namespace?: string): string | undefined { - return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE ?? undefined; +function resolveQueueNamespace(namespace: string | undefined) { + return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE; } -function getQueueTopicPrefix(kind: 'workflow' | 'step', namespace?: string) { +function getQueueTopicPrefix( + kind: 'workflow' | 'step', + namespace: string | undefined +) { if (namespace !== undefined) { - if (!QUEUE_NAMESPACE_PATTERN.test(namespace)) { - throw new Error( - `Invalid queue namespace "${namespace}": must be lowercase alphanumeric, starting with a letter` - ); - } - + QueueNamespaceSchema.parse(namespace); return `__${namespace}_wkf_${kind}_`; } @@ -29,18 +27,18 @@ function getQueueTopicPrefix(kind: 'workflow' | 'step', namespace?: string) { * * @example * // default: topic = '__wkf_workflow_*' - * createWorkflowQueueTrigger() + * createWorkflowQueueTrigger(undefined) * * @example * // namespaced: topic = '__custom_wkf_workflow_*' - * createWorkflowQueueTrigger({ namespace: 'custom' }) + * createWorkflowQueueTrigger('custom') */ -export function createWorkflowQueueTrigger(options?: { namespace?: string }) { - const namespace = resolveQueueNamespace(options?.namespace); +export function createWorkflowQueueTrigger(namespace: string | undefined) { + const resolvedNamespace = resolveQueueNamespace(namespace); return { type: 'queue/v2beta' as const, - topic: `${getQueueTopicPrefix('workflow', namespace)}*`, + topic: `${getQueueTopicPrefix('workflow', resolvedNamespace)}*`, consumer: 'default', retryAfterSeconds: 5, // Delay between retries (default: 60) initialDelaySeconds: 0, // Initial delay before first delivery (default: 0) @@ -52,22 +50,22 @@ export function createWorkflowQueueTrigger(options?: { namespace?: string }) { * calls. The namespace is resolved while building so generated route files do * not need `WORKFLOW_QUEUE_NAMESPACE` at runtime. */ -export function createWorkflowEntrypointOptionsCode(options?: { - namespace?: string; -}) { - const namespace = resolveQueueNamespace(options?.namespace); +export function createWorkflowEntrypointOptionsCode( + namespace: string | undefined +) { + const resolvedNamespace = resolveQueueNamespace(namespace); - if (!namespace) { + if (!resolvedNamespace) { return ''; } // Reuse prefix construction for namespace validation. - getQueueTopicPrefix('workflow', namespace); + getQueueTopicPrefix('workflow', resolvedNamespace); - return `, { namespace: ${JSON.stringify(namespace)} }`; + return `, { namespace: ${JSON.stringify(resolvedNamespace)} }`; } /** * Default queue trigger (no namespace). Backward compatible. */ -export const WORKFLOW_QUEUE_TRIGGER = createWorkflowQueueTrigger(); +export const WORKFLOW_QUEUE_TRIGGER = createWorkflowQueueTrigger(undefined); diff --git a/packages/builders/src/resolve-sourcemap.test.ts b/packages/builders/src/resolve-sourcemap.test.ts index 774e8a7a36..d077a86748 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,14 @@ function createBuilder( workflowsBundlePath: '', webhookBundlePath: '', sourcemap, - watch, + watch: options.watch, + workflowConfig: + options.workflowSourcemap === undefined + ? undefined + : { + found: false, + config: { build: { sourcemap: options.workflowSourcemap } }, + }, }; return new TestBuilder(config); } @@ -78,12 +85,23 @@ describe('resolveSourcemap', () => { it('prefers explicit config over environment variable', () => { process.env.WORKFLOW_SOURCEMAP = 'inline'; - expect(createBuilder(false).callResolveSourcemap('inline')).toBe(false); + expect( + createBuilder(false, { watch: true }).callResolveSourcemap('inline') + ).toBe(false); expect(createBuilder('external').callResolveSourcemap('inline')).toBe( 'external' ); }); + it('prefers workflow.config.ts over environment variable', () => { + process.env.WORKFLOW_SOURCEMAP = 'inline'; + expect( + createBuilder(undefined, { workflowSourcemap: false }).callResolveSourcemap( + true + ) + ).toBe(false); + }); + it('uses environment variable when config is not set', () => { process.env.WORKFLOW_SOURCEMAP = 'false'; expect(createBuilder().callResolveSourcemap('inline')).toBe(false); @@ -152,7 +170,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 +212,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/runtime-config-plugin.ts b/packages/builders/src/runtime-config-plugin.ts new file mode 100644 index 0000000000..79bb727a54 --- /dev/null +++ b/packages/builders/src/runtime-config-plugin.ts @@ -0,0 +1,15 @@ +import type { Plugin } from 'esbuild'; + +export function createRuntimeConfigPlugin(runtimeConfigPath: string): Plugin { + return { + name: 'workflow-runtime-config', + setup(build) { + build.onResolve( + { filter: /^@workflow\/config\/runtime-binding$/ }, + () => ({ + path: runtimeConfigPath, + }) + ); + }, + }; +} diff --git a/packages/builders/src/types.ts b/packages/builders/src/types.ts index 74fcd3b885..b58b4c804b 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; diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts index 03fb55ffdf..da771620e3 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,7 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder { // serves no purpose without maps. shouldAddSourcemapSupport: this.sourcemapsEnabled, maxDuration: 'max', - experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER], + experimentalTriggers: [createWorkflowQueueTrigger(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..85d8a26a88 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 workflow.config.ts', }), }; @@ -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..353295f743 100644 --- a/packages/cli/src/lib/config/workflow-config.ts +++ b/packages/cli/src/lib/config/workflow-config.ts @@ -1,5 +1,7 @@ -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 { const raw = process.env.WORKFLOW_OBSERVABILITY_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 = resolveObservabilityCwd(); + 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..f1c6ea0056 100644 --- a/packages/cli/src/lib/inspect/env.ts +++ b/packages/cli/src/lib/inspect/env.ts @@ -80,7 +80,7 @@ async function findManifestPath(cwd: string) { */ export const inferLocalWorldEnvVars = async () => { const envVars = getEnvVars(); - const cwd = getWorkflowConfig().workingDir; + const cwd = (await getWorkflowConfig()).workingDir; 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 = (await getWorkflowConfig()).workingDir; 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..db04bf1c72 --- /dev/null +++ b/packages/config/README.md @@ -0,0 +1,27 @@ +# @workflow/config + +Typed, shared configuration for Workflow SDK. + +Import it through `workflow/config`: + +```ts +import { defineConfig } from 'workflow/config'; +import { postgresWorld } from '@workflow/world-postgres'; + +export default defineConfig({ + world: postgresWorld({ + connectionString: () => process.env.WORKFLOW_POSTGRES_URL!, + }), + build: { + dirs: ['workflows'], + sourcemap: false, + }, + integration: { + type: 'next', + lazyDiscovery: true, + }, +}); +``` + +See the [configuration guide](https://workflow-sdk.dev/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..c4d08e6246 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,61 @@ +{ + "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:*", + "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..4c8ea79b2a --- /dev/null +++ b/packages/config/src/index.ts @@ -0,0 +1,14 @@ +export { + defineWorldProvider, + type ProviderValue, + type WorldProvider, +} from '@workflow/world'; +export type { WorkflowConfigLoader } from './load.js'; +export type { SourcemapMode, WorkflowConfig } from './schema.js'; +export { WorkflowConfigSchema } from './schema.js'; + +import type { WorkflowConfig } from './schema.js'; + +export function defineConfig(config: WorkflowConfig): WorkflowConfig { + return config; +} 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..75cf527835 --- /dev/null +++ b/packages/config/src/load.test.ts @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { World } from '@workflow/world'; +import { defineWorldProvider } from '@workflow/world'; +import { afterEach, describe, expect, it } from 'vitest'; +import { loadWorkflowConfig } from './load.js'; +import { WorkflowConfigSchema } from './schema.js'; + +const tempDirs: string[] = []; + +function createProject(): string { + const project = mkdtempSync(join(tmpdir(), 'workflow-config-')); + mkdirSync(join(project, '.git')); + tempDirs.push(project); + return project; +} + +function writeFile(path: string, contents: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents, 'utf8'); +} + +afterEach(() => { + 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(); + const app = join(project, 'apps', 'web'); + writeFile( + join(project, 'workflow.config.ts'), + `export default { build: { dirs: ['parent'] } };` + ); + writeFile( + join(app, 'workflow.config.ts'), + `export default { + build: { dirs: ['app'], sourcemap: false }, + integration: { type: 'next', lazyDiscovery: false } + };` + ); + + const loaded = await loadWorkflowConfig({ + cwd: app, + integration: 'next', + }); + + assert(loaded.found); + expect(loaded.path).toBe(join(app, 'workflow.config.ts')); + expect(loaded.config).toEqual({ + build: { dirs: ['app'], sourcemap: false }, + integration: { type: 'next', lazyDiscovery: false }, + }); + }); + + it('rejects multiple config files in one directory', async () => { + const project = createProject(); + writeFile( + join(project, 'workflow.config.ts'), + `export default { build: { dirs: ['typescript'] } };` + ); + writeFile( + join(project, '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(); + const app = join(project, 'app'); + writeFile( + join(app, 'workflow.config.json'), + JSON.stringify({ build: { dirs: ['workflows'] } }) + ); + + await expect(loadWorkflowConfig({ cwd: app })).rejects.toThrow( + 'Unsupported Workflow config file' + ); + }); + + it('rejects integration config for another platform', async () => { + const project = createProject(); + writeFile( + join(project, '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 config functions and unknown keys', async () => { + const project = createProject(); + writeFile( + join(project, 'workflow.config.ts'), + `export default () => ({ build: { dirs: ['workflows'] } });` + ); + + await expect(loadWorkflowConfig({ cwd: project })).rejects.toThrow( + 'must default-export a static object' + ); + + const promiseProject = createProject(); + writeFile( + join(promiseProject, '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('accepts a typed inert WorldProvider', () => { + const provider = defineWorldProvider({ + id: 'test-world', + create: () => ({}) as World, + }); + + expect(WorkflowConfigSchema.parse({ world: provider })).toEqual({ + world: provider, + }); + expect(() => WorkflowConfigSchema.parse({ world: {} })).toThrow(); + }); + + 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..17a387f0ee --- /dev/null +++ b/packages/config/src/load.ts @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { + basename, + dirname, + extname, + isAbsolute, + join, + resolve, +} from 'node:path'; +import { createJiti } from 'jiti'; +import { + type WorkflowConfig, + WorkflowConfigSchema, + type WorkflowIntegrationType, +} from './schema.js'; + +const WORKFLOW_CONFIG_FILES = [ + 'workflow.config.ts', + 'workflow.config.mts', + 'workflow.config.js', + 'workflow.config.mjs', +] as const; + +const UNSUPPORTED_WORKFLOW_CONFIG_FILES = [ + 'workflow.config.cjs', + 'workflow.config.cts', + 'workflow.config.json', + 'workflow.config.jsx', + 'workflow.config.tsx', +] as const; + +export type LoadWorkflowConfigOptions = { + cwd: string; + configFile?: string; + integration?: WorkflowIntegrationType; +}; + +export type LoadedWorkflowConfig = + | { + found: false; + config: WorkflowConfig; + } + | { + found: true; + path: string; + config: WorkflowConfig; + }; + +function isSearchRoot(dir: string): boolean { + if ( + existsSync(join(dir, '.git')) || + existsSync(join(dir, 'pnpm-workspace.yaml')) + ) { + return true; + } + + const packageJsonPath = join(dir, 'package.json'); + if (!existsSync(packageJsonPath)) { + return false; + } + + const packageJson: unknown = JSON.parse( + readFileSync(packageJsonPath, 'utf8') + ); + assert( + packageJson !== null && + typeof packageJson === 'object' && + !Array.isArray(packageJson), + `${packageJsonPath} must contain an object.` + ); + return 'workspaces' in packageJson; +} + +function discoverWorkflowConfig({ + cwd, + configFile, +}: Pick): string | undefined { + if (configFile) { + const path = isAbsolute(configFile) ? configFile : resolve(cwd, configFile); + assert( + ['.ts', '.mts', '.js', '.mjs'].includes(extname(path)), + `Unsupported Workflow config extension "${extname(path)}".` + ); + assert( + existsSync(path) && statSync(path).isFile(), + `Workflow config file not found: ${path}` + ); + return path; + } + + let dir = resolve(cwd); + while (true) { + const unsupported = UNSUPPORTED_WORKFLOW_CONFIG_FILES.filter((file) => + existsSync(join(dir, file)) + ); + assert( + unsupported.length === 0, + `Unsupported Workflow config file "${unsupported[0]}".` + ); + + const configs = WORKFLOW_CONFIG_FILES.filter((file) => + existsSync(join(dir, file)) + ); + assert( + configs.length <= 1, + `Multiple Workflow config files found in ${dir}: ${configs.join(', ')}` + ); + if (configs[0]) { + return join(dir, configs[0]); + } + + if (isSearchRoot(dir)) { + return; + } + + const parent = dirname(dir); + if (parent === dir) { + return; + } + dir = parent; + } +} + +export async function loadWorkflowConfig( + options: LoadWorkflowConfigOptions +): Promise { + const path = discoverWorkflowConfig(options); + if (!path) { + return { found: false, 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}".` + ); + + return { found: true, path, config }; +} + +export type WorkflowConfigLoader = typeof loadWorkflowConfig; diff --git a/packages/config/src/runtime-binding.ts b/packages/config/src/runtime-binding.ts new file mode 100644 index 0000000000..728f8b8b1a --- /dev/null +++ b/packages/config/src/runtime-binding.ts @@ -0,0 +1,5 @@ +import type { WorkflowConfig } from './schema.js'; + +const config: WorkflowConfig | 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..79bdc802f3 --- /dev/null +++ b/packages/config/src/runtime.ts @@ -0,0 +1,17 @@ +import type { WorkflowConfig } from './schema.js'; + +const RuntimeWorkflowConfig = Symbol.for('@workflow/config/runtime'); + +const globals = globalThis as typeof globalThis & { + [RuntimeWorkflowConfig]?: WorkflowConfig; +}; + +export function getRuntimeWorkflowConfig(): WorkflowConfig | undefined { + return globals[RuntimeWorkflowConfig]; +} + +export function setRuntimeWorkflowConfig( + config: WorkflowConfig | undefined +): void { + globals[RuntimeWorkflowConfig] = config; +} diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts new file mode 100644 index 0000000000..1e6ba4c915 --- /dev/null +++ b/packages/config/src/schema.ts @@ -0,0 +1,62 @@ +import { QueueNamespaceSchema, WorldProviderSchema } from '@workflow/world'; +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'), + lazyDiscovery: z.boolean().optional(), + 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: WorldProviderSchema.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..4f512b4652 --- /dev/null +++ b/packages/core/src/runtime/world-config.test.ts @@ -0,0 +1,132 @@ +import { setRuntimeWorkflowConfig } from '@workflow/config/runtime'; +import type { World } from '@workflow/world'; +import { + defineWorldProvider, + setWorkflowQueueNamespace, +} from '@workflow/world'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + closeWorld, + createWorld, + getWorld, + getWorldHandlers, + setWorld, +} from './world.js'; + +const originalTargetWorld = process.env.WORKFLOW_TARGET_WORLD; + +function mockWorld(overrides: Partial = {}): World { + return { + createQueueHandler: vi.fn(), + ...overrides, + } as unknown as World; +} + +afterEach(async () => { + await closeWorld(); + setWorld(undefined); + setRuntimeWorkflowConfig(undefined); + setWorkflowQueueNamespace(undefined); + vi.restoreAllMocks(); + if (originalTargetWorld === undefined) { + delete process.env.WORKFLOW_TARGET_WORLD; + } else { + process.env.WORKFLOW_TARGET_WORLD = originalTargetWorld; + } +}); + +describe('configured World lifecycle', () => { + it('creates and starts one shared World at runtime', async () => { + delete process.env.WORKFLOW_TARGET_WORLD; + const start = vi.fn(async () => {}); + const close = vi.fn(async () => {}); + const world = mockWorld({ start, close, specVersion: 4 }); + const create = vi.fn(async () => world); + setRuntimeWorkflowConfig({ + world: defineWorldProvider({ + id: 'test-world', + create, + }), + queue: { namespace: 'myapp' }, + }); + + const [resolvedWorld, handlers] = await Promise.all([ + getWorld(), + getWorldHandlers(), + ]); + + expect(resolvedWorld).toBe(world); + expect(handlers.specVersion).toBe(4); + expect(create).toHaveBeenCalledOnce(); + expect(create).toHaveBeenCalledWith(); + expect(start).toHaveBeenCalledOnce(); + + await closeWorld(); + expect(close).toHaveBeenCalledOnce(); + }); + + it('does not start a fresh World returned by createWorld()', async () => { + delete process.env.WORKFLOW_TARGET_WORLD; + const start = vi.fn(async () => {}); + setRuntimeWorkflowConfig({ + world: defineWorldProvider({ + id: 'test-world', + create: () => mockWorld({ start }), + }), + }); + + await createWorld(); + + expect(start).not.toHaveBeenCalled(); + }); + + it('clears a failed provider promise so the next call can retry', async () => { + delete process.env.WORKFLOW_TARGET_WORLD; + const world = mockWorld(); + const create = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('not ready')) + .mockResolvedValueOnce(world); + setRuntimeWorkflowConfig({ + world: defineWorldProvider({ + id: 'test-world', + create, + }), + }); + + await expect(getWorld()).rejects.toThrow('not ready'); + await expect(getWorld()).resolves.toBe(world); + expect(create).toHaveBeenCalledTimes(2); + }); + + it('does not instantiate a World during cleanup', async () => { + const create = vi.fn(() => mockWorld()); + setRuntimeWorkflowConfig({ + world: defineWorldProvider({ + id: 'test-world', + create, + }), + }); + + await closeWorld(); + + expect(create).not.toHaveBeenCalled(); + }); + + it('prefers configured providers over WORKFLOW_TARGET_WORLD', async () => { + process.env.WORKFLOW_TARGET_WORLD = 'local'; + const world = mockWorld(); + setRuntimeWorkflowConfig({ + world: defineWorldProvider({ + id: 'test-world', + create: () => world, + }), + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await expect(getWorld()).resolves.toBe(world); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('WORKFLOW_TARGET_WORLD="local" is ignored') + ); + }); +}); diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index fcece0c860..e4d82ef409 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -1,10 +1,14 @@ import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; +import { type WorkflowConfig, WorkflowConfigSchema } from '@workflow/config'; +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'; import { createLocalWorld } from '@workflow/world-local'; import { createVercelWorld } from '@workflow/world-vercel'; @@ -14,24 +18,20 @@ 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 RuntimeConfigPromise = Symbol.for('@workflow/config//cachePromise'); const globalSymbols: typeof globalThis & { [WorldCache]?: World; - [StubbedWorldCache]?: World; [WorldCachePromise]?: Promise; - [StubbedWorldCachePromise]?: Promise; + [RuntimeConfigPromise]?: Promise; } = globalThis; // Dynamic import for custom world modules. Uses a standard import() @@ -53,7 +53,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 +76,32 @@ 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 loadRuntimeWorkflowConfig(): Promise { + if (boundWorkflowConfig !== undefined) { + return WorkflowConfigSchema.parse(boundWorkflowConfig); + } + + const installedConfig = getRuntimeWorkflowConfig(); + if (installedConfig !== undefined) { + return WorkflowConfigSchema.parse(installedConfig); + } + + if (!globalSymbols[RuntimeConfigPromise]) { + globalSymbols[RuntimeConfigPromise] = import('@workflow/config/load') + .then(({ loadWorkflowConfig }) => + loadWorkflowConfig({ cwd: process.cwd() }) + ) + .then(({ config }) => config) + .catch((error) => { + globalSymbols[RuntimeConfigPromise] = undefined; + throw error; + }); + } + + return globalSymbols[RuntimeConfigPromise]; +} + +async function createLegacyWorld(): Promise { const targetWorld = resolveWorkflowTargetWorld(); if (isVercelWorldTarget(targetWorld)) { @@ -129,36 +154,58 @@ 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 = await loadRuntimeWorkflowConfig(); + setWorkflowQueueNamespace(config.queue?.namespace); + + if (config.world) { + if (process.env.WORKFLOW_TARGET_WORLD) { + console.warn( + `[workflow] The Workflow config provides World provider "${config.world.id}", so WORKFLOW_TARGET_WORLD="${process.env.WORKFLOW_TARGET_WORLD}" is ignored.` + ); + } + + return { + type: 'configured', + world: await config.world.create(), + }; + } + + return { + type: 'legacy', + world: await createLegacyWorld(), + }; +} + +/** + * Create a new World instance from workflow.config.ts when configured, or + * from the legacy WORKFLOW_TARGET_WORLD environment selection. + * + * 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. Provider + * factories are 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 +216,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 +244,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/nest/package.json b/packages/nest/package.json index 47c8a3142e..93be84ab38 100644 --- a/packages/nest/package.json +++ b/packages/nest/package.json @@ -34,6 +34,7 @@ "dependencies": { "@swc/core": "catalog:", "@workflow/builders": "workspace:*", + "@workflow/config": "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..43c4e07ec9 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 = + options.outDir ?? + integration?.outDir ?? + join(workingDir, '.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.module.test.ts b/packages/nest/src/workflow.module.test.ts new file mode 100644 index 0000000000..8290307a39 --- /dev/null +++ b/packages/nest/src/workflow.module.test.ts @@ -0,0 +1,84 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { Module } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { NestLocalBuilder } from './builder.js'; +import { WorkflowModule } from './workflow.module.js'; + +const tempDirs: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('WorkflowModule workflow.config.ts', () => { + it('loads Nest and generic build settings before creating the builder', async () => { + const project = mkdtempSync(join(tmpdir(), 'workflow-nest-config-')); + tempDirs.push(project); + mkdirSync(join(project, '.git')); + writeFileSync( + join(project, 'workflow.config.ts'), + `export default { + build: { + dirs: ['src/jobs'], + projectRoot: '..', + externalPackages: ['sharp'], + sourcemap: false, + manifest: { output: 'workflow-manifest.json' } + }, + queue: { namespace: 'myapp' }, + integration: { + type: 'nest', + moduleType: 'commonjs', + outDir: '.generated/workflow', + distDir: 'build', + watch: true + } +};` + ); + + let builder: NestLocalBuilder | undefined; + let builderConfig: Record | undefined; + vi.spyOn(NestLocalBuilder.prototype, 'build').mockImplementation( + async function (this: NestLocalBuilder) { + builder = this; + builderConfig = (this as unknown as { config: Record }) + .config; + } + ); + + @Module({ + imports: [WorkflowModule.forRoot({ workingDir: project })], + }) + class AppModule {} + + const app = await NestFactory.createApplicationContext(AppModule, { + logger: false, + }); + + expect(builderConfig).toMatchObject({ + dirs: ['src/jobs'], + workingDir: project, + projectRoot: resolve(project, '..'), + externalPackages: ['sharp'], + watch: true, + workflowConfig: { + found: true, + path: join(project, 'workflow.config.ts'), + config: { + build: { + manifest: { output: 'workflow-manifest.json' }, + }, + queue: { namespace: 'myapp' }, + }, + }, + }); + expect(builder?.outDir).toBe('.generated/workflow'); + await app.close(); + }); +}); diff --git a/packages/nest/src/workflow.module.ts b/packages/nest/src/workflow.module.ts index f6578fed68..55462d23ad 100644 --- a/packages/nest/src/workflow.module.ts +++ b/packages/nest/src/workflow.module.ts @@ -1,11 +1,11 @@ import { type DynamicModule, + Inject, Module, - type OnModuleDestroy, type OnModuleInit, } from '@nestjs/common'; import { createBuildQueue } from '@workflow/builders'; -import { join } from 'pathe'; +import { loadWorkflowConfig } from '@workflow/config/load'; import { type NestBuilderOptions, NestLocalBuilder } from './builder.js'; import { configureWorkflowController, @@ -20,17 +20,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; +export class WorkflowModule implements OnModuleInit { 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 +46,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 +60,24 @@ 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 integration = workflowConfig.config.integration; + const builder = new NestLocalBuilder({ + ...this.options, + workflowConfig, + }); + + configureWorkflowController(builder.outDir); + if ( + this.options.skipBuild ?? + (integration?.type === 'nest' ? integration.skipBuild : false) + ) + return; - async onModuleDestroy() { - // Cleanup if needed - WorkflowModule.builder = null; + await WorkflowModule.buildQueue(() => builder.build()); } } diff --git a/packages/next/README.md b/packages/next/README.md index 1bc4db3736..f672f7b4e9 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -1,3 +1,24 @@ # @workflow/next Next.js plugin for [Workflow SDK](https://workflow-sdk.dev). + +Shared build, World, queue, and Next-specific settings can live in +`workflow.config.ts`: + +```ts +import { defineConfig } from 'workflow/config'; +import { localWorld } from '@workflow/world-local'; + +export default defineConfig({ + world: localWorld(), + build: { sourcemap: false }, + integration: { + type: 'next', + lazyDiscovery: true, + }, +}); +``` + +Wrap `next.config.ts` with `withWorkflow()` to activate directive transforms. +Values passed in its optional second argument take precedence over +`workflow.config.ts`. 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..3d781af014 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")' @@ -435,7 +435,9 @@ export async function getNextBuilderEager() { version: '0', workflows: { maxDuration: 'max', - experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER], + experimentalTriggers: [ + createWorkflowQueueTrigger(this.queueNamespace), + ], }, }; diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 6996e331b3..0b7abdac58 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, resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { @@ -219,6 +219,81 @@ 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); + mkdirSync(join(projectDir, '.git')); + writeFile( + join(projectDir, 'workflow.config.ts'), + `const world = { + type: 'world-provider', + id: 'configured-world', + create: () => { + throw new Error('World provider factory must not run during builds'); + } +}; + +export default { + world, + 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 } + } +};` + ); + try { + const config = withWorkflow({}); + const resolvedConfig = await config('phase-production-build', { + defaultConfig: {}, + }); + + expect(process.env.PORT).toBe('4321'); + expect(process.env.WORKFLOW_TARGET_WORLD).toBeUndefined(); + expect(builderConfigs[0]).toMatchObject({ + dirs: ['jobs'], + projectRoot: resolve(projectDir, '../repo-root'), + workflowConfig: { + found: true, + path: join(projectDir, 'workflow.config.ts'), + config: { + build: { + sourcemap: false, + manifest: { + public: true, + output: 'custom-manifest.json', + }, + }, + queue: { namespace: 'myapp' }, + }, + }, + }); + expect(builderConfigs[0]?.externalPackages).toContain( + 'configured-external' + ); + expect(resolvedConfig.serverExternalPackages).toContain( + 'configured-world' + ); + expect( + (resolvedConfig.turbopack?.resolveAlias as Record)[ + '@workflow/config/runtime-binding' + ] + ).toBe(join(projectDir, 'workflow.config.ts')); + expect(resolvedConfig.outputFileTracingIncludes?.['/*']).toContain( + 'workflow.config.ts' + ); + } 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..c624a30903 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'; @@ -28,6 +29,16 @@ const workflowSerdeComputedPropertyPattern = const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']); const warnedAutoRemovedServerExternalPackages = new Set(); +async function loadWorkflowConfigForNext() { + const { loadWorkflowConfig } = require('@workflow/config/load') as { + loadWorkflowConfig: WorkflowConfigLoader; + }; + return loadWorkflowConfig({ + cwd: process.cwd(), + integration: 'next', + }); +} + interface WorkflowPatternMatch { hasUseWorkflow: boolean; hasUseStep: boolean; @@ -331,25 +342,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 } @@ -375,9 +371,40 @@ export function withWorkflow( } // shallow clone to avoid read-only on top-level nextConfig = Object.assign({}, nextConfig); + + const loadedWorkflowConfig = await loadWorkflowConfigForNext(); + const workflowConfig = loadedWorkflowConfig.config; + const runtimeConfigPath = loadedWorkflowConfig.found + ? loadedWorkflowConfig.path + : undefined; + 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'; + } + const localPort = workflows?.local?.port ?? nextIntegration?.local?.port; + if (localPort !== undefined) { + process.env.PORT = localPort.toString(); + } + } else if (!workflowConfig.world && !process.env.WORKFLOW_TARGET_WORLD) { + process.env.WORKFLOW_TARGET_WORLD = 'vercel'; + } + + const configuredWorldPackage = + workflowConfig.world && + isResolvablePackageSpecifier(workflowConfig.world.id) + ? workflowConfig.world.id + : undefined; nextConfig.serverExternalPackages = [ ...new Set([ ...(nextConfig.serverExternalPackages || []), + ...(workflowConfig.build?.externalPackages || []), + ...(configuredWorldPackage ? [configuredWorldPackage] : []), // 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 +468,33 @@ export function withWorkflow( if (!nextConfig.turbopack.rules) { nextConfig.turbopack.rules = {}; } + if (runtimeConfigPath) { + const existingResolveAlias = isPlainObject( + nextConfig.turbopack.resolveAlias + ) + ? nextConfig.turbopack.resolveAlias + : {}; + nextConfig.turbopack.resolveAlias = { + ...existingResolveAlias, + '@workflow/config/runtime-binding': runtimeConfigPath, + }; + + const tracedConfigPath = relative( + process.cwd(), + runtimeConfigPath + ).replaceAll('\\', '/'); + const existingTracingIncludes = + nextConfig.outputFileTracingIncludes || {}; + nextConfig.outputFileTracingIncludes = { + ...existingTracingIncludes, + '/*': [ + ...new Set([ + ...(existingTracingIncludes['/*'] || []), + tracedConfigPath, + ]), + ], + }; + } const existingRules = nextConfig.turbopack.rules as any; const nextVersion = resolveNextVersion(process.cwd()); const supportsTurboCondition = semver.gte(nextVersion, 'v16.0.0'); @@ -466,15 +520,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 +542,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 +609,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..405da975d4 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -4,8 +4,9 @@ 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 @@ -27,15 +28,30 @@ function getNitroStringExternals(nitro: Nitro): string[] | undefined { return strings && strings.length > 0 ? strings : undefined; } +function mergeExternalPackages( + ...groups: Array +): string[] | undefined { + const packages = [...new Set(groups.flatMap((group) => group ?? []))]; + return packages.length > 0 ? packages : undefined; +} + export class VercelBuilder extends VercelBuildOutputAPIBuilder { - constructor(nitro: Nitro) { + constructor(nitro: Nitro, loadedConfig: LoadedWorkflowConfig) { + const buildConfig = loadedConfig.config.build; super({ ...createBaseBuilderConfig({ workingDir: nitro.options.rootDir, - dirs: ['.'], // Different apps that use nitro have different directories + dirs: nitro.options.workflow?.dirs ?? buildConfig?.dirs ?? ['.'], + projectRoot: buildConfig?.projectRoot + ? resolve(nitro.options.rootDir, buildConfig.projectRoot) + : undefined, runtime: nitro.options.workflow?.runtime, sourcemap: nitro.options.workflow?.sourcemap, - externalPackages: getNitroStringExternals(nitro), + externalPackages: mergeExternalPackages( + buildConfig?.externalPackages, + getNitroStringExternals(nitro) + ), + workflowConfig: loadedConfig, }), buildTarget: 'vercel-build-output-api', }); @@ -55,15 +71,23 @@ 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'); + const buildConfig = loadedConfig.config.build; super({ ...createBaseBuilderConfig({ workingDir: nitro.options.rootDir, watch: nitro.options.dev, - dirs: ['.'], // Different apps that use nitro have different directories + dirs: nitro.options.workflow?.dirs ?? buildConfig?.dirs ?? ['.'], + projectRoot: buildConfig?.projectRoot + ? resolve(nitro.options.rootDir, buildConfig.projectRoot) + : undefined, sourcemap: nitro.options.workflow?.sourcemap, - externalPackages: getNitroStringExternals(nitro), + externalPackages: mergeExternalPackages( + buildConfig?.externalPackages, + getNitroStringExternals(nitro) + ), + workflowConfig: loadedConfig, }), buildTarget: 'next', // Placeholder, not actually used }); diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts index ffb638fbad..c5f7270458 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -1,3 +1,6 @@ +import { mkdirSync, 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 { LocalBuilder, VercelBuilder } from './builders.js'; @@ -9,6 +12,7 @@ type StubOptions = { dev?: boolean; preset?: string; workflow?: { runtime?: string }; + rootDir?: string; externals?: { external?: Array boolean)>; }; @@ -21,6 +25,7 @@ function createNitroStub({ dev = false, preset = 'node-server', workflow = {}, + rootDir = '/tmp/project', externals, vercel, }: StubOptions) { @@ -34,7 +39,7 @@ function createNitroStub({ externals: externals ?? {}, handlers: [], preset, - rootDir: '/tmp/project', + rootDir, typescript: {}, vercel: vercel ?? {}, virtual: {}, @@ -110,6 +115,66 @@ describe('@workflow/nitro virtual handlers', () => { }); }); +describe('@workflow/nitro workflow.config.ts', () => { + it('applies typed Nitro settings and a namespaced queue trigger', async () => { + const project = mkdtempSync(join(tmpdir(), 'workflow-nitro-config-')); + mkdirSync(join(project, '.git')); + writeFileSync( + join(project, 'workflow.config.ts'), + `export default { + build: { + dirs: ['server/jobs'], + sourcemap: false, + manifest: { public: true } + }, + queue: { namespace: 'myapp' }, + integration: { + type: 'nitro', + typescriptPlugin: true, + runtime: 'nodejs24.x' + } +};` + ); + + try { + const nitro = createNitroStub({ + routing: true, + preset: 'vercel', + rootDir: project, + }); + + await nitroModule.setup(nitro); + + expect(nitro.options.workflow).toMatchObject({ + dirs: ['server/jobs'], + sourcemap: false, + 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); + } finally { + rmSync(project, { recursive: true, force: true }); + } + }); +}); + describe('@workflow/nitro Vercel functionRules', () => { it('does not configure functionRules outside of Vercel deploys', async () => { const nitro = createNitroStub({ routing: true }); @@ -309,6 +374,8 @@ describe('@workflow/nitro isNitroV2 detection', () => { }); describe('@workflow/nitro externals forwarding', () => { + const loadedConfig = { found: false, config: {} } as const; + for (const [label, Builder] of [ ['VercelBuilder', VercelBuilder], ['LocalBuilder', LocalBuilder], @@ -316,7 +383,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 +392,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 +403,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 +412,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..64226bacb3 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'; @@ -29,6 +30,34 @@ function isNitroV2(nitro: Nitro): boolean { export default { name: 'workflow/nitro', async setup(nitro: Nitro) { + const loadedWorkflowConfig = await loadWorkflowConfig({ + cwd: nitro.options.rootDir, + integration: 'nitro', + }); + const workflowConfig = loadedWorkflowConfig.config; + const runtimeConfigPath = loadedWorkflowConfig.found + ? loadedWorkflowConfig.path + : undefined; + 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, + sourcemap: + nitro.options.workflow?.sourcemap ?? workflowConfig.build?.sourcemap, + }; + const publicManifest = + workflowConfig.build?.manifest?.public ?? + process.env.WORKFLOW_PUBLIC_MANIFEST === '1'; + const workflowQueueTrigger = createWorkflowQueueTrigger( + workflowConfig.queue?.namespace + ); const isVercelDeploy = !nitro.options.dev && nitro.options.preset === 'vercel'; @@ -38,7 +67,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 +86,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 +204,7 @@ export default { if (useLegacyVercelBuild) { nitro.hooks.hook('compiled', async () => { - await new VercelBuilder(nitro).build(); + await new VercelBuilder(nitro, loadedWorkflowConfig).build(); }); } @@ -174,7 +215,7 @@ 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 builder = new LocalBuilder(nitro, loadedWorkflowConfig); let isInitialBuild = true; nitro.hooks.hook('build:before', async () => { @@ -183,10 +224,7 @@ export default { // 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); } }); @@ -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 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..46d9d70e06 100644 --- a/packages/nitro/src/vite.ts +++ b/packages/nitro/src/vite.ts @@ -1,4 +1,5 @@ import { createBuildQueue } from '@workflow/builders'; +import { loadWorkflowConfig } from '@workflow/config/load'; import { workflowTransformPlugin } from '@workflow/rollup'; import { workflowHotUpdatePlugin } from '@workflow/vite'; import type { Nitro } from 'nitro/types'; @@ -10,7 +11,7 @@ import type { ModuleOptions } 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 +34,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 +42,14 @@ export function workflow(options?: ModuleOptions): Plugin[] { ...options, _vite: true, }; + await nitroModule.setup(nitro); if (nitro.options.dev) { - builder = new LocalBuilder(nitro); + const loadedWorkflowConfig = await loadWorkflowConfig({ + cwd: nitro.options.rootDir, + integration: 'nitro', + }); + builder = new LocalBuilder(nitro, loadedWorkflowConfig); } - return 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-local/README.md b/packages/world-local/README.md index 9e3f0d95cc..e4a550c523 100644 --- a/packages/world-local/README.md +++ b/packages/world-local/README.md @@ -6,3 +6,18 @@ Stores workflow data as JSON files on disk and provides in-memory queuing. Autom Used by default on `next dev` and `next start`. +## workflow.config.ts + +Use `localWorld()` in `workflow.config.ts`: + +```ts +import { defineConfig } from 'workflow/config'; +import { localWorld } from '@workflow/world-local'; + +export default defineConfig({ + world: localWorld({ + dataDir: '.workflow-data', + port: 3000, + }), +}); +``` diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts index f028dccc3d..67ab7c86d0 100644 --- a/packages/world-local/src/index.ts +++ b/packages/world-local/src/index.ts @@ -1,8 +1,12 @@ import { promises as fs } from 'node:fs'; import { rm } from 'node:fs/promises'; import path from 'node:path'; -import type { QueuePrefix, World } from '@workflow/world'; -import { reenqueueActiveRuns, SPEC_VERSION_CURRENT } from '@workflow/world'; +import type { QueuePrefix, World, WorldProvider } from '@workflow/world'; +import { + defineWorldProvider, + reenqueueActiveRuns, + SPEC_VERSION_CURRENT, +} from '@workflow/world'; import type { Config } from './config.js'; import { config } from './config.js'; import { @@ -20,6 +24,7 @@ import { hashToken, hookRecoveryMarkerPath } from './storage/helpers.js'; import { createStorage } from './storage.js'; import { createStreamer } from './streamer.js'; +export type { Config as LocalWorldConfig } from './config.js'; // Re-export init types and utilities for consumers export { DataDirAccessError, @@ -29,7 +34,6 @@ export { type ParsedVersion, parseVersion, } from './init.js'; - export type { DirectHandler } from './queue.js'; export type LocalWorld = World & { @@ -172,3 +176,11 @@ export function createLocalWorld(args?: Partial): LocalWorld { }, }; } + +/** Creates a local provider for workflow.config.ts. */ +export function localWorld(args?: Partial): WorldProvider { + return defineWorldProvider({ + id: '@workflow/world-local', + create: () => createLocalWorld(args), + }); +} diff --git a/packages/world-postgres/HOW_IT_WORKS.md b/packages/world-postgres/HOW_IT_WORKS.md index 4c13a2a20e..8fbb8ac78e 100644 --- a/packages/world-postgres/HOW_IT_WORKS.md +++ b/packages/world-postgres/HOW_IT_WORKS.md @@ -19,7 +19,7 @@ graph LR PG -.-> S["${prefix}steps
(steps)"] ``` -Jobs include retry logic (3 attempts), idempotency keys, durable delayed rescheduling, and configurable worker concurrency (default: 10). +Jobs include retry logic (3 attempts), idempotency keys, durable delayed rescheduling, and configurable worker concurrency (default: 50). ## Streaming @@ -33,23 +33,31 @@ 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. +Call `world.start()` to initialize graphile-worker workers when constructing a +World directly. A `postgresWorld()` provider configured in +`workflow.config.ts` is started once by `getWorld()`. + +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. 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 +ensure a configured provider starts 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(); }); } ``` + +When using `createWorld()` directly instead of `postgresWorld()`, call +`world.start()` yourself. diff --git a/packages/world-postgres/README.md b/packages/world-postgres/README.md index d23ceefeae..75539dbd3c 100644 --- a/packages/world-postgres/README.md +++ b/packages/world-postgres/README.md @@ -61,6 +61,24 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const worldFromPool = createWorld({ pool }); ``` +### workflow.config.ts + +Use `postgresWorld()` in `workflow.config.ts`: + +```typescript +import { defineConfig } from 'workflow/config'; +import { postgresWorld } from '@workflow/world-postgres'; + +export default defineConfig({ + world: postgresWorld({ + connectionString: () => process.env.WORKFLOW_POSTGRES_URL!, + jobPrefix: 'myapp_', + queueConcurrency: 50, + maxPoolSize: 52, + }), +}); +``` + ## Configuration Options | Option | Type | Default | Description | diff --git a/packages/world-postgres/src/index.ts b/packages/world-postgres/src/index.ts index 9ad7565e06..2e4be4603d 100644 --- a/packages/world-postgres/src/index.ts +++ b/packages/world-postgres/src/index.ts @@ -1,5 +1,15 @@ -import type { Storage, World } from '@workflow/world'; -import { reenqueueActiveRuns, SPEC_VERSION_CURRENT } from '@workflow/world'; +import type { + ProviderValue, + Storage, + World, + WorldProvider, +} from '@workflow/world'; +import { + defineWorldProvider, + reenqueueActiveRuns, + resolveProviderValue, + SPEC_VERSION_CURRENT, +} from '@workflow/world'; import { Pool } from 'pg'; import type { PostgresWorldConfig } from './config.js'; import { createClient, type Drizzle } from './drizzle/index.js'; @@ -30,29 +40,36 @@ function getDefaultMaxPoolSize(): number | undefined { return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } +function getDefaultQueueConcurrency(): number { + return ( + parseInt(process.env.WORKFLOW_POSTGRES_WORKER_CONCURRENCY || '50', 10) || 50 + ); +} + export function createWorld( config: PostgresWorldConfig = { connectionString: process.env.WORKFLOW_POSTGRES_URL || 'postgres://world:world@localhost:5432/world', - jobPrefix: process.env.WORKFLOW_POSTGRES_JOB_PREFIX, - queueConcurrency: - parseInt(process.env.WORKFLOW_POSTGRES_WORKER_CONCURRENCY || '50', 10) || - 50, } ): World & { start(): Promise } { - const maxPoolSize = config.maxPoolSize ?? getDefaultMaxPoolSize(); + const resolvedConfig = { + ...config, + jobPrefix: config.jobPrefix ?? process.env.WORKFLOW_POSTGRES_JOB_PREFIX, + queueConcurrency: config.queueConcurrency ?? getDefaultQueueConcurrency(), + }; + const maxPoolSize = resolvedConfig.maxPoolSize ?? getDefaultMaxPoolSize(); const pool = - config.pool || + resolvedConfig.pool || new Pool({ connectionString: - config.connectionString || + resolvedConfig.connectionString || 'postgres://world:world@localhost:5432/world', ...(maxPoolSize !== undefined ? { max: maxPoolSize } : {}), }); const drizzle = createClient(pool); - const queue = createQueue(config, pool); + const queue = createQueue(resolvedConfig, pool); const storage = createStorage(drizzle); const streamer = createStreamer(pool, drizzle); @@ -61,8 +78,8 @@ export function createWorld( ...storage, ...streamer, ...queue, - ...(config.streamFlushIntervalMs !== undefined && { - streamFlushIntervalMs: config.streamFlushIntervalMs, + ...(resolvedConfig.streamFlushIntervalMs !== undefined && { + streamFlushIntervalMs: resolvedConfig.streamFlushIntervalMs, }), async start() { await queue.start(); @@ -71,13 +88,41 @@ export function createWorld( async close() { await streamer.close(); await queue.close(); - if (pool !== config.pool) { + if (pool !== resolvedConfig.pool) { await pool.end(); } }, }; } +export type PostgresWorldProviderConfig = Omit< + Extract, + 'connectionString' | 'namespace' | 'pool' +> & { + connectionString?: ProviderValue; +}; + +/** Creates a PostgreSQL provider for workflow.config.ts. */ +export function postgresWorld( + config: PostgresWorldProviderConfig = {} +): WorldProvider { + return defineWorldProvider({ + id: '@workflow/world-postgres', + create: () => + createWorld({ + connectionString: + config.connectionString === undefined + ? process.env.WORKFLOW_POSTGRES_URL || + 'postgres://world:world@localhost:5432/world' + : resolveProviderValue(config.connectionString), + jobPrefix: config.jobPrefix, + queueConcurrency: config.queueConcurrency, + maxPoolSize: config.maxPoolSize, + streamFlushIntervalMs: config.streamFlushIntervalMs, + }), + }); +} + // Re-export schema for users who want to extend or inspect the database schema export type { PostgresWorldConfig } from './config.js'; export * from './drizzle/schema.js'; diff --git a/packages/world-vercel/README.md b/packages/world-vercel/README.md index 21ce59cc53..8aa622f1f0 100644 --- a/packages/world-vercel/README.md +++ b/packages/world-vercel/README.md @@ -6,6 +6,19 @@ Integrates with Vercel's infrastructure for storage, queuing, and authentication Used by default for deployments on Vercel. Authentication and API endpoints are configured automatically in Vercel deployments. +## workflow.config.ts + +Use `vercelWorld()` to select the Vercel backend explicitly: + +```ts +import { defineConfig } from 'workflow/config'; +import { vercelWorld } from '@workflow/world-vercel'; + +export default defineConfig({ + world: vercelWorld(), +}); +``` + ## Custom dispatcher HTTP requests (including the queue) default to a shared undici `RetryAgent` that handles connection pooling and retries. Pass a custom `dispatcher` to override it — e.g. to tune undici on newer Node runtimes: @@ -17,4 +30,3 @@ import { setWorld } from '@workflow/core/runtime'; setWorld(createVercelWorld({ dispatcher: new Agent({ connections: 16 }) })); ``` - diff --git a/packages/world-vercel/src/index.ts b/packages/world-vercel/src/index.ts index 0bdaa91ed6..3ed350cf6b 100644 --- a/packages/world-vercel/src/index.ts +++ b/packages/world-vercel/src/index.ts @@ -1,5 +1,9 @@ -import type { World } from '@workflow/world'; -import { SPEC_VERSION_SUPPORTS_COMPRESSION } from '@workflow/world'; +import type { ProviderValue, World, WorldProvider } from '@workflow/world'; +import { + defineWorldProvider, + resolveProviderValue, + SPEC_VERSION_SUPPORTS_COMPRESSION, +} from '@workflow/world'; import { createGetEncryptionKeyForRun } from './encryption.js'; import { instrumentObject } from './instrumentObject.js'; import { createQueue } from './queue.js'; @@ -51,3 +55,32 @@ export function createVercelWorld(config?: APIConfig): World { resolveLatestDeploymentId: createResolveLatestDeploymentId(config), }; } + +export type VercelWorldProviderConfig = Omit< + APIConfig, + 'token' | 'dispatcher' +> & { + token?: ProviderValue; + dispatcher?: ProviderValue; +}; + +/** Creates a Vercel provider for workflow.config.ts. */ +export function vercelWorld( + config: VercelWorldProviderConfig = {} +): WorldProvider { + return defineWorldProvider({ + id: '@workflow/world-vercel', + create: () => + createVercelWorld({ + ...config, + token: + config.token === undefined + ? undefined + : resolveProviderValue(config.token), + dispatcher: + config.dispatcher === undefined + ? undefined + : resolveProviderValue(config.dispatcher), + }), + }); +} diff --git a/packages/world/README.md b/packages/world/README.md index c5f28b767e..3880657f16 100644 --- a/packages/world/README.md +++ b/packages/world/README.md @@ -4,4 +4,20 @@ 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. +It also defines the `WorldProvider` contract used by `workflow.config.ts`. + +Custom World packages can expose a typed helper with `defineWorldProvider()`: + +```ts +import { defineWorldProvider } from '@workflow/world'; + +export function hybridWorld(options: HybridOptions) { + return defineWorldProvider({ + id: '@acme/workflow-world', + create: () => createHybridWorld(options), + }); +} +``` + +Most applications should use a provider helper from a World implementation +instead of importing this package directly. diff --git a/packages/world/src/index.ts b/packages/world/src/index.ts index 85ae00c274..e1c6b78832 100644 --- a/packages/world/src/index.ts +++ b/packages/world/src/index.ts @@ -24,6 +24,12 @@ export { export type * from './hooks.js'; export { HookSchema } from './hooks.js'; export type * from './interfaces.js'; +export type * from './provider.js'; +export { + defineWorldProvider, + resolveProviderValue, + WorldProviderSchema, +} from './provider.js'; export type * from './queue.js'; export { getQueuePrefixKind, @@ -31,11 +37,13 @@ export { HealthCheckPayloadSchema, MessageId, parseQueueName, + QueueNamespaceSchema, QueuePayloadSchema, QueuePrefix, RunInputSchema, resolveQueueNamespace, StepInvokePayloadSchema, + setWorkflowQueueNamespace, ValidQueueName, WorkflowInvokePayloadSchema, } from './queue.js'; diff --git a/packages/world/src/provider.ts b/packages/world/src/provider.ts new file mode 100644 index 0000000000..d819967c34 --- /dev/null +++ b/packages/world/src/provider.ts @@ -0,0 +1,27 @@ +import { z } from 'zod/v4'; +import type { World } from './interfaces.js'; + +export type ProviderValue = T | (() => T); + +type WorldFactory = () => World | Promise; + +export const WorldProviderSchema = z.strictObject({ + type: z.literal('world-provider'), + id: z.string().trim().min(1), + create: z.custom((value) => typeof value === 'function'), +}); + +export type WorldProvider = z.infer; + +export function defineWorldProvider( + provider: Omit +): WorldProvider { + return WorldProviderSchema.parse({ + type: 'world-provider', + ...provider, + }); +} + +export function resolveProviderValue(value: ProviderValue): T { + return typeof value === 'function' ? (value as () => T)() : value; +} diff --git a/packages/world/src/queue.ts b/packages/world/src/queue.ts index f60ae034a7..d1d4493ae6 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 still take precedence. + */ +export function setWorkflowQueueNamespace(namespace: string | undefined): void { + queueGlobals[WorkflowQueueNamespace] = namespace; +} + +/** + * Resolves the active queue namespace from an explicit argument, the loaded + * Workflow config, or the legacy WORKFLOW_QUEUE_NAMESPACE env var. */ export function resolveQueueNamespace(namespace?: string): string | undefined { - return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE ?? undefined; + return ( + namespace ?? + queueGlobals[WorkflowQueueNamespace] ?? + process.env.WORKFLOW_QUEUE_NAMESPACE + ); } /** @@ -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..cbededa66e 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,28 @@ importers: specifier: workspace:* version: link:../tsconfig + packages/config: + dependencies: + '@workflow/world': + specifier: workspace:* + version: link:../world + 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 +595,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 +760,9 @@ importers: '@workflow/builders': specifier: workspace:* version: link:../builders + '@workflow/config': + specifier: workspace:* + version: link:../config '@workflow/swc-plugin': specifier: workspace:* version: link:../swc-plugin-workflow @@ -760,6 +797,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 +840,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 +1328,9 @@ importers: '@workflow/cli': specifier: workspace:* version: link:../cli + '@workflow/config': + specifier: workspace:* + version: link:../config '@workflow/core': specifier: workspace:* version: link:../core @@ -16515,6 +16561,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -36383,7 +36430,7 @@ snapshots: vitest@4.0.18(@opentelemetry/api@1.9.0)(@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): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) + '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 From 8fc47782ccc857447b94244efc9f6a6f441a0ffd Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:34:48 -0700 Subject: [PATCH 02/15] Honor environment config precedence Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .../workflow-next/with-workflow.mdx | 2 +- .../docs/v5/foundations/configuration.mdx | 36 ++++++++++++- packages/builders/src/base-builder.ts | 22 ++++---- .../builders/src/resolve-sourcemap.test.ts | 4 +- packages/builders/src/types.ts | 3 +- .../core/src/runtime/world-config.test.ts | 50 ++++++++++++++++--- packages/core/src/runtime/world.ts | 17 ++++--- packages/next/README.md | 4 +- packages/next/src/index.test.ts | 41 +++++++++++++++ packages/next/src/index.ts | 10 ++-- packages/nitro/src/index.test.ts | 50 ++++++++++++++++++- packages/nitro/src/index.ts | 10 ++-- packages/world-local/README.md | 2 + packages/world-local/src/index.ts | 11 +++- packages/world-postgres/README.md | 2 + packages/world-postgres/src/index.ts | 28 +++++++---- packages/world/README.md | 2 + packages/world/src/queue.test.ts | 31 +++++++++++- packages/world/src/queue.ts | 8 +-- 19 files changed, 275 insertions(+), 58 deletions(-) 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 a0a2d8ccc5..a408698588 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 @@ -86,7 +86,7 @@ Build and Next.js integration settings can be placed in [`workflow.config.ts`](/docs/foundations/configuration). `withWorkflow` also accepts an optional second argument. Values passed there -take precedence over `workflow.config.ts`. +take precedence over environment variables and `workflow.config.ts`. ```typescript title="next.config.ts" lineNumbers import type { NextConfig } from "next"; diff --git a/docs/content/docs/v5/foundations/configuration.mdx b/docs/content/docs/v5/foundations/configuration.mdx index 0790332c17..1a1af0af3e 100644 --- a/docs/content/docs/v5/foundations/configuration.mdx +++ b/docs/content/docs/v5/foundations/configuration.mdx @@ -49,8 +49,8 @@ used. From highest to lowest priority: 1. Explicit CLI flags or framework options -2. `workflow.config.ts` -3. Environment variables +2. Environment variables +3. `workflow.config.ts` 4. Built-in defaults ## World Providers @@ -91,6 +91,38 @@ postgresWorld({ Provider fields that accept a callback read its value when the World is created. +## Different Worlds by Environment + +Choose a World inside the provider factory when one build must run against +different infrastructure in different environments: + +```typescript title="workflow.config.ts" lineNumbers +import { defineConfig } from "workflow/config"; +import { defineWorldProvider } from "@workflow/world"; +import { createLocalWorld } from "@workflow/world-local"; +import { createVercelWorld } from "@workflow/world-vercel"; + +const world = defineWorldProvider({ + id: "app-world", + create() { + switch (process.env.NODE_ENV) { + case "production": + return createVercelWorld(); + case "development": + case "test": + return createLocalWorld(); + default: + throw new Error(`Unexpected NODE_ENV: ${process.env.NODE_ENV}`); + } + }, +}); + +export default defineConfig({ world }); +``` + +The factory runs when the application first needs the World. Builds load the +provider definition without creating either backend. + ## Integration Settings `integration` is optional and only needed for integration-specific behavior. diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 6ce707da88..57ad0316be 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -230,7 +230,10 @@ export abstract class BaseBuilder { } protected get queueNamespace(): string | undefined { - return this.config.workflowConfig?.config.queue?.namespace; + return ( + process.env.WORKFLOW_QUEUE_NAMESPACE ?? + this.config.workflowConfig?.config.queue?.namespace + ); } private get runtimeConfigPlugins(): esbuild.Plugin[] { @@ -2092,13 +2095,14 @@ export const OPTIONS = handler;`; /** * Whether the manifest should be exposed as a public HTTP route. - * workflow.config.ts takes precedence over WORKFLOW_PUBLIC_MANIFEST. + * WORKFLOW_PUBLIC_MANIFEST takes precedence over workflow.config.ts. */ protected get shouldExposePublicManifest(): boolean { - return ( - this.config.workflowConfig?.config.build?.manifest?.public ?? - 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; } /** @@ -2147,16 +2151,16 @@ export const OPTIONS = handler;`; /** * Resolve the effective source map mode for a given call site. Precedence: - * builder option > workflow.config.ts > WORKFLOW_SOURCEMAP > the call site's + * 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 configMode = this.config.workflowConfig?.config.build?.sourcemap; - if (configMode !== undefined) return configMode; 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/resolve-sourcemap.test.ts b/packages/builders/src/resolve-sourcemap.test.ts index d077a86748..e7ec8cbf52 100644 --- a/packages/builders/src/resolve-sourcemap.test.ts +++ b/packages/builders/src/resolve-sourcemap.test.ts @@ -93,13 +93,13 @@ describe('resolveSourcemap', () => { ); }); - it('prefers workflow.config.ts over environment variable', () => { + it('prefers environment variable over workflow.config.ts', () => { process.env.WORKFLOW_SOURCEMAP = 'inline'; expect( createBuilder(undefined, { workflowSourcemap: false }).callResolveSourcemap( true ) - ).toBe(false); + ).toBe('inline'); }); it('uses environment variable when config is not set', () => { diff --git a/packages/builders/src/types.ts b/packages/builders/src/types.ts index b58b4c804b..3543c8275b 100644 --- a/packages/builders/src/types.ts +++ b/packages/builders/src/types.ts @@ -86,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/core/src/runtime/world-config.test.ts b/packages/core/src/runtime/world-config.test.ts index 4f512b4652..e8d2807c2e 100644 --- a/packages/core/src/runtime/world-config.test.ts +++ b/packages/core/src/runtime/world-config.test.ts @@ -14,6 +14,7 @@ import { } from './world.js'; const originalTargetWorld = process.env.WORKFLOW_TARGET_WORLD; +const originalNodeEnv = process.env.NODE_ENV; function mockWorld(overrides: Partial = {}): World { return { @@ -33,6 +34,11 @@ afterEach(async () => { } else { process.env.WORKFLOW_TARGET_WORLD = originalTargetWorld; } + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } }); describe('configured World lifecycle', () => { @@ -113,20 +119,48 @@ describe('configured World lifecycle', () => { expect(create).not.toHaveBeenCalled(); }); - it('prefers configured providers over WORKFLOW_TARGET_WORLD', async () => { + it('prefers WORKFLOW_TARGET_WORLD over configured providers', async () => { process.env.WORKFLOW_TARGET_WORLD = 'local'; - const world = mockWorld(); + const create = vi.fn(() => mockWorld()); setRuntimeWorkflowConfig({ world: defineWorldProvider({ id: 'test-world', - create: () => world, + create, }), }); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - await expect(getWorld()).resolves.toBe(world); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('WORKFLOW_TARGET_WORLD="local" is ignored') - ); + await getWorld(); + + expect(create).not.toHaveBeenCalled(); + }); + + it('selects a World when the provider factory runs', async () => { + delete process.env.WORKFLOW_TARGET_WORLD; + const developmentWorld = mockWorld(); + const productionWorld = mockWorld(); + const create = vi.fn(() => { + switch (process.env.NODE_ENV) { + case 'development': + return developmentWorld; + case 'production': + return productionWorld; + default: + throw new Error(`Unexpected NODE_ENV: ${process.env.NODE_ENV}`); + } + }); + setRuntimeWorkflowConfig({ + world: defineWorldProvider({ + id: 'environment-world', + create, + }), + }); + + process.env.NODE_ENV = 'development'; + await expect(getWorld()).resolves.toBe(developmentWorld); + await closeWorld(); + + process.env.NODE_ENV = 'production'; + await expect(getWorld()).resolves.toBe(productionWorld); + expect(create).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index e4d82ef409..769d1af535 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -164,13 +164,14 @@ async function resolveWorld(): Promise { const config = await loadRuntimeWorkflowConfig(); setWorkflowQueueNamespace(config.queue?.namespace); - if (config.world) { - if (process.env.WORKFLOW_TARGET_WORLD) { - console.warn( - `[workflow] The Workflow config provides World provider "${config.world.id}", so WORKFLOW_TARGET_WORLD="${process.env.WORKFLOW_TARGET_WORLD}" is ignored.` - ); - } + if (process.env.WORKFLOW_TARGET_WORLD) { + return { + type: 'legacy', + world: await createLegacyWorld(), + }; + } + if (config.world) { return { type: 'configured', world: await config.world.create(), @@ -184,8 +185,8 @@ async function resolveWorld(): Promise { } /** - * Create a new World instance from workflow.config.ts when configured, or - * from the legacy WORKFLOW_TARGET_WORLD environment selection. + * 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. diff --git a/packages/next/README.md b/packages/next/README.md index f672f7b4e9..9a9829639d 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -20,5 +20,5 @@ export default defineConfig({ ``` Wrap `next.config.ts` with `withWorkflow()` to activate directive transforms. -Values passed in its optional second argument take precedence over -`workflow.config.ts`. +Values passed in its optional second argument take precedence over environment +variables and `workflow.config.ts`. diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 0b7abdac58..4e299c69cb 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -219,6 +219,47 @@ describe('withWorkflow builder config', () => { expect(webpackConfig?.externals).toEqual([{ react: 'commonjs react' }]); }); + it('lets an explicit local port override PORT', async () => { + process.env.PORT = '3000'; + + const config = withWorkflow( + {}, + { + workflows: { + local: { port: 4000 }, + }, + } + ); + await config('phase-production-build', { defaultConfig: {} }); + + expect(process.env.PORT).toBe('4000'); + }); + + it('prefers environment variables over workflow.config.ts', async () => { + const projectDir = mkdtempSync(join(realTmpDir, 'workflow-next-config-')); + process.chdir(projectDir); + mkdirSync(join(projectDir, '.git')); + writeFile( + join(projectDir, 'workflow.config.ts'), + `export default { + integration: { + type: 'next', + local: { port: 4321 } + } +};` + ); + process.env.PORT = '9876'; + + try { + const config = withWorkflow({}); + await config('phase-production-build', { defaultConfig: {} }); + + expect(process.env.PORT).toBe('9876'); + } finally { + process.chdir(originalCwd); + rmSync(projectDir, { recursive: true, force: true }); + } + }); it('applies workflow.config.ts to the Next builder and runtime binding', async () => { const projectDir = mkdtempSync(join(realTmpDir, 'workflow-next-config-')); process.chdir(projectDir); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index c624a30903..8ec582f065 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -387,9 +387,13 @@ export function withWorkflow( process.env.WORKFLOW_TARGET_WORLD = 'local'; process.env.WORKFLOW_LOCAL_DATA_DIR = '.next/workflow-data'; } - const localPort = workflows?.local?.port ?? nextIntegration?.local?.port; - if (localPort !== undefined) { - process.env.PORT = localPort.toString(); + if (workflows?.local?.port !== undefined) { + process.env.PORT = workflows.local.port.toString(); + } 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'; diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts index c5f7270458..6690edad42 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -147,7 +147,6 @@ describe('@workflow/nitro workflow.config.ts', () => { expect(nitro.options.workflow).toMatchObject({ dirs: ['server/jobs'], - sourcemap: false, typescriptPlugin: true, runtime: 'nodejs24.x', }); @@ -173,6 +172,55 @@ describe('@workflow/nitro workflow.config.ts', () => { rmSync(project, { recursive: true, force: true }); } }); + + it('prefers environment variables over workflow.config.ts', async () => { + const project = mkdtempSync(join(tmpdir(), 'workflow-nitro-config-')); + mkdirSync(join(project, '.git')); + writeFileSync( + join(project, 'workflow.config.ts'), + `export default { + build: { manifest: { public: true } }, + queue: { namespace: 'configured' } +};` + ); + const queueNamespace = process.env.WORKFLOW_QUEUE_NAMESPACE; + const publicManifest = process.env.WORKFLOW_PUBLIC_MANIFEST; + process.env.WORKFLOW_QUEUE_NAMESPACE = 'environment'; + process.env.WORKFLOW_PUBLIC_MANIFEST = '0'; + + try { + 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); + } finally { + if (queueNamespace === undefined) { + delete process.env.WORKFLOW_QUEUE_NAMESPACE; + } else { + process.env.WORKFLOW_QUEUE_NAMESPACE = queueNamespace; + } + if (publicManifest === undefined) { + delete process.env.WORKFLOW_PUBLIC_MANIFEST; + } else { + process.env.WORKFLOW_PUBLIC_MANIFEST = publicManifest; + } + rmSync(project, { recursive: true, force: true }); + } + }); }); describe('@workflow/nitro Vercel functionRules', () => { diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index 64226bacb3..ea9d7e5a65 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -49,14 +49,14 @@ export default { nitro.options.workflow?.typescriptPlugin ?? nitroIntegration?.typescriptPlugin, runtime: nitro.options.workflow?.runtime ?? nitroIntegration?.runtime, - sourcemap: - nitro.options.workflow?.sourcemap ?? workflowConfig.build?.sourcemap, + sourcemap: nitro.options.workflow?.sourcemap, }; const publicManifest = - workflowConfig.build?.manifest?.public ?? - process.env.WORKFLOW_PUBLIC_MANIFEST === '1'; + process.env.WORKFLOW_PUBLIC_MANIFEST === undefined + ? (workflowConfig.build?.manifest?.public ?? false) + : process.env.WORKFLOW_PUBLIC_MANIFEST === '1'; const workflowQueueTrigger = createWorkflowQueueTrigger( - workflowConfig.queue?.namespace + process.env.WORKFLOW_QUEUE_NAMESPACE ?? workflowConfig.queue?.namespace ); const isVercelDeploy = !nitro.options.dev && nitro.options.preset === 'vercel'; diff --git a/packages/world-local/README.md b/packages/world-local/README.md index e4a550c523..6327033165 100644 --- a/packages/world-local/README.md +++ b/packages/world-local/README.md @@ -21,3 +21,5 @@ export default defineConfig({ }), }); ``` + +Environment variables take precedence over values in `workflow.config.ts`. diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts index 67ab7c86d0..cadb816204 100644 --- a/packages/world-local/src/index.ts +++ b/packages/world-local/src/index.ts @@ -181,6 +181,15 @@ export function createLocalWorld(args?: Partial): LocalWorld { export function localWorld(args?: Partial): WorldProvider { return defineWorldProvider({ id: '@workflow/world-local', - create: () => createLocalWorld(args), + create: () => + createLocalWorld({ + ...args, + dataDir: process.env.WORKFLOW_LOCAL_DATA_DIR ?? args?.dataDir, + baseUrl: + process.env.WORKFLOW_LOCAL_BASE_URL ?? + (process.env.PORT + ? `http://localhost:${process.env.PORT}` + : args?.baseUrl), + }), }); } diff --git a/packages/world-postgres/README.md b/packages/world-postgres/README.md index 75539dbd3c..2b83feeb9a 100644 --- a/packages/world-postgres/README.md +++ b/packages/world-postgres/README.md @@ -79,6 +79,8 @@ export default defineConfig({ }); ``` +Environment variables take precedence over values passed to `postgresWorld()`. + ## Configuration Options | Option | Type | Default | Description | diff --git a/packages/world-postgres/src/index.ts b/packages/world-postgres/src/index.ts index 2e4be4603d..859bef1b97 100644 --- a/packages/world-postgres/src/index.ts +++ b/packages/world-postgres/src/index.ts @@ -40,10 +40,17 @@ function getDefaultMaxPoolSize(): number | undefined { return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } -function getDefaultQueueConcurrency(): number { - return ( - parseInt(process.env.WORKFLOW_POSTGRES_WORKER_CONCURRENCY || '50', 10) || 50 +function getQueueConcurrencyFromEnv(): number | undefined { + const parsed = parseInt( + process.env.WORKFLOW_POSTGRES_WORKER_CONCURRENCY || '', + 10 ); + + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function getDefaultQueueConcurrency(): number { + return getQueueConcurrencyFromEnv() ?? 50; } export function createWorld( @@ -111,13 +118,14 @@ export function postgresWorld( create: () => createWorld({ connectionString: - config.connectionString === undefined - ? process.env.WORKFLOW_POSTGRES_URL || - 'postgres://world:world@localhost:5432/world' - : resolveProviderValue(config.connectionString), - jobPrefix: config.jobPrefix, - queueConcurrency: config.queueConcurrency, - maxPoolSize: config.maxPoolSize, + process.env.WORKFLOW_POSTGRES_URL || + (config.connectionString === undefined + ? 'postgres://world:world@localhost:5432/world' + : resolveProviderValue(config.connectionString)), + jobPrefix: process.env.WORKFLOW_POSTGRES_JOB_PREFIX ?? config.jobPrefix, + queueConcurrency: + getQueueConcurrencyFromEnv() ?? config.queueConcurrency, + maxPoolSize: getDefaultMaxPoolSize() ?? config.maxPoolSize, streamFlushIntervalMs: config.streamFlushIntervalMs, }), }); diff --git a/packages/world/README.md b/packages/world/README.md index 3880657f16..a92a1242dd 100644 --- a/packages/world/README.md +++ b/packages/world/README.md @@ -8,6 +8,8 @@ It also defines the `WorldProvider` contract used by `workflow.config.ts`. Custom World packages can expose a typed helper with `defineWorldProvider()`: + + ```ts import { defineWorldProvider } from '@workflow/world'; 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 d1d4493ae6..df99565ebd 100644 --- a/packages/world/src/queue.ts +++ b/packages/world/src/queue.ts @@ -40,7 +40,7 @@ const queueGlobals = globalThis as typeof globalThis & { /** * Sets the process-local queue namespace resolved from workflow.config.ts. - * Explicit function arguments still take precedence. + * Explicit function arguments and WORKFLOW_QUEUE_NAMESPACE take precedence. */ export function setWorkflowQueueNamespace(namespace: string | undefined): void { queueGlobals[WorkflowQueueNamespace] = namespace; @@ -48,13 +48,13 @@ export function setWorkflowQueueNamespace(namespace: string | undefined): void { /** * Resolves the active queue namespace from an explicit argument, the loaded - * Workflow config, or the legacy WORKFLOW_QUEUE_NAMESPACE env var. + * WORKFLOW_QUEUE_NAMESPACE env var, or the loaded Workflow config. */ export function resolveQueueNamespace(namespace?: string): string | undefined { return ( namespace ?? - queueGlobals[WorkflowQueueNamespace] ?? - process.env.WORKFLOW_QUEUE_NAMESPACE + process.env.WORKFLOW_QUEUE_NAMESPACE ?? + queueGlobals[WorkflowQueueNamespace] ); } From 0b0e98adde4e28879f1904fbcccf47ac3acb3d0b Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:57:18 -0700 Subject: [PATCH 03/15] Remove world provider identifiers Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- docs/content/docs/v5/foundations/configuration.mdx | 2 -- packages/config/src/load.test.ts | 1 - packages/core/src/runtime/world-config.test.ts | 6 ------ packages/next/src/index.test.ts | 13 ++++++------- packages/next/src/index.ts | 14 +++++++------- packages/world-local/src/index.ts | 1 - packages/world-postgres/src/index.ts | 1 - packages/world-vercel/src/index.ts | 1 - packages/world/README.md | 1 - packages/world/src/provider.ts | 1 - 10 files changed, 13 insertions(+), 28 deletions(-) diff --git a/docs/content/docs/v5/foundations/configuration.mdx b/docs/content/docs/v5/foundations/configuration.mdx index 1a1af0af3e..5f6a3ed2f4 100644 --- a/docs/content/docs/v5/foundations/configuration.mdx +++ b/docs/content/docs/v5/foundations/configuration.mdx @@ -72,7 +72,6 @@ import { defineWorldProvider } from "@workflow/world"; export function hybridWorld(options: HybridOptions) { return defineWorldProvider({ - id: "@acme/workflow-world", create: () => createHybridWorld(options), }); } @@ -103,7 +102,6 @@ import { createLocalWorld } from "@workflow/world-local"; import { createVercelWorld } from "@workflow/world-vercel"; const world = defineWorldProvider({ - id: "app-world", create() { switch (process.env.NODE_ENV) { case "production": diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index 75cf527835..26028f12cd 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -128,7 +128,6 @@ describe('loadWorkflowConfig', () => { it('accepts a typed inert WorldProvider', () => { const provider = defineWorldProvider({ - id: 'test-world', create: () => ({}) as World, }); diff --git a/packages/core/src/runtime/world-config.test.ts b/packages/core/src/runtime/world-config.test.ts index e8d2807c2e..cb2daa5254 100644 --- a/packages/core/src/runtime/world-config.test.ts +++ b/packages/core/src/runtime/world-config.test.ts @@ -50,7 +50,6 @@ describe('configured World lifecycle', () => { const create = vi.fn(async () => world); setRuntimeWorkflowConfig({ world: defineWorldProvider({ - id: 'test-world', create, }), queue: { namespace: 'myapp' }, @@ -76,7 +75,6 @@ describe('configured World lifecycle', () => { const start = vi.fn(async () => {}); setRuntimeWorkflowConfig({ world: defineWorldProvider({ - id: 'test-world', create: () => mockWorld({ start }), }), }); @@ -95,7 +93,6 @@ describe('configured World lifecycle', () => { .mockResolvedValueOnce(world); setRuntimeWorkflowConfig({ world: defineWorldProvider({ - id: 'test-world', create, }), }); @@ -109,7 +106,6 @@ describe('configured World lifecycle', () => { const create = vi.fn(() => mockWorld()); setRuntimeWorkflowConfig({ world: defineWorldProvider({ - id: 'test-world', create, }), }); @@ -124,7 +120,6 @@ describe('configured World lifecycle', () => { const create = vi.fn(() => mockWorld()); setRuntimeWorkflowConfig({ world: defineWorldProvider({ - id: 'test-world', create, }), }); @@ -150,7 +145,6 @@ describe('configured World lifecycle', () => { }); setRuntimeWorkflowConfig({ world: defineWorldProvider({ - id: 'environment-world', create, }), }); diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 4e299c69cb..5176af7d26 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, resolve } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { @@ -268,7 +268,6 @@ describe('withWorkflow builder config', () => { join(projectDir, 'workflow.config.ts'), `const world = { type: 'world-provider', - id: 'configured-world', create: () => { throw new Error('World provider factory must not run during builds'); } @@ -291,7 +290,8 @@ export default { };` ); try { - const config = withWorkflow({}); + const turbopackRoot = dirname(projectDir); + const config = withWorkflow({ turbopack: { root: turbopackRoot } }); const resolvedConfig = await config('phase-production-build', { defaultConfig: {}, }); @@ -319,14 +319,13 @@ export default { expect(builderConfigs[0]?.externalPackages).toContain( 'configured-external' ); - expect(resolvedConfig.serverExternalPackages).toContain( - 'configured-world' - ); expect( (resolvedConfig.turbopack?.resolveAlias as Record)[ '@workflow/config/runtime-binding' ] - ).toBe(join(projectDir, 'workflow.config.ts')); + ).toBe( + `./${relative(turbopackRoot, join(projectDir, 'workflow.config.ts'))}` + ); expect(resolvedConfig.outputFileTracingIncludes?.['/*']).toContain( 'workflow.config.ts' ); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 8ec582f065..26411abe66 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -399,16 +399,10 @@ export function withWorkflow( process.env.WORKFLOW_TARGET_WORLD = 'vercel'; } - const configuredWorldPackage = - workflowConfig.world && - isResolvablePackageSpecifier(workflowConfig.world.id) - ? workflowConfig.world.id - : undefined; nextConfig.serverExternalPackages = [ ...new Set([ ...(nextConfig.serverExternalPackages || []), ...(workflowConfig.build?.externalPackages || []), - ...(configuredWorldPackage ? [configuredWorldPackage] : []), // Keep the Vercel world and its native-prone dependencies external so // local builds do not try to parse @vercel/queue's keyring dependency // tree. @@ -478,9 +472,15 @@ export function withWorkflow( ) ? nextConfig.turbopack.resolveAlias : {}; + const runtimeConfigRequest = relative( + nextConfig.turbopack.root ?? process.cwd(), + runtimeConfigPath + ).replaceAll('\\', '/'); nextConfig.turbopack.resolveAlias = { ...existingResolveAlias, - '@workflow/config/runtime-binding': runtimeConfigPath, + '@workflow/config/runtime-binding': runtimeConfigRequest.startsWith('.') + ? runtimeConfigRequest + : `./${runtimeConfigRequest}`, }; const tracedConfigPath = relative( diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts index cadb816204..a6eacd3073 100644 --- a/packages/world-local/src/index.ts +++ b/packages/world-local/src/index.ts @@ -180,7 +180,6 @@ export function createLocalWorld(args?: Partial): LocalWorld { /** Creates a local provider for workflow.config.ts. */ export function localWorld(args?: Partial): WorldProvider { return defineWorldProvider({ - id: '@workflow/world-local', create: () => createLocalWorld({ ...args, diff --git a/packages/world-postgres/src/index.ts b/packages/world-postgres/src/index.ts index 859bef1b97..488bc6fca8 100644 --- a/packages/world-postgres/src/index.ts +++ b/packages/world-postgres/src/index.ts @@ -114,7 +114,6 @@ export function postgresWorld( config: PostgresWorldProviderConfig = {} ): WorldProvider { return defineWorldProvider({ - id: '@workflow/world-postgres', create: () => createWorld({ connectionString: diff --git a/packages/world-vercel/src/index.ts b/packages/world-vercel/src/index.ts index 3ed350cf6b..b1513faa15 100644 --- a/packages/world-vercel/src/index.ts +++ b/packages/world-vercel/src/index.ts @@ -69,7 +69,6 @@ export function vercelWorld( config: VercelWorldProviderConfig = {} ): WorldProvider { return defineWorldProvider({ - id: '@workflow/world-vercel', create: () => createVercelWorld({ ...config, diff --git a/packages/world/README.md b/packages/world/README.md index a92a1242dd..b32032236d 100644 --- a/packages/world/README.md +++ b/packages/world/README.md @@ -15,7 +15,6 @@ import { defineWorldProvider } from '@workflow/world'; export function hybridWorld(options: HybridOptions) { return defineWorldProvider({ - id: '@acme/workflow-world', create: () => createHybridWorld(options), }); } diff --git a/packages/world/src/provider.ts b/packages/world/src/provider.ts index d819967c34..7d9855ad11 100644 --- a/packages/world/src/provider.ts +++ b/packages/world/src/provider.ts @@ -7,7 +7,6 @@ type WorldFactory = () => World | Promise; export const WorldProviderSchema = z.strictObject({ type: z.literal('world-provider'), - id: z.string().trim().min(1), create: z.custom((value) => typeof value === 'function'), }); From f50cf0e499a4a11339166a036c7f047dbfe0ad3d Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:20:40 -0700 Subject: [PATCH 04/15] Simplify unified workflow configuration Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .../workflow-next/with-workflow.mdx | 2 +- .../docs/v5/foundations/configuration.mdx | 42 ++--- packages/builders/src/base-builder.ts | 17 +- packages/builders/src/constants.test.ts | 12 +- packages/builders/src/constants.ts | 24 +-- .../builders/src/resolve-sourcemap.test.ts | 2 +- .../builders/src/runtime-config-plugin.ts | 15 -- packages/cli/src/commands/build.ts | 2 +- .../cli/src/lib/config/workflow-config.ts | 4 +- packages/cli/src/lib/inspect/env.ts | 6 +- packages/config/package.json | 1 + packages/config/src/index.ts | 9 +- packages/config/src/load.test.ts | 94 ++++------- packages/config/src/load.ts | 130 +++++---------- packages/config/src/schema.ts | 3 +- .../core/src/runtime/world-config.test.ts | 149 +++--------------- packages/core/src/runtime/world.ts | 32 +--- packages/nest/package.json | 1 + packages/nest/src/builder.ts | 8 +- packages/nest/src/workflow.controller.ts | 9 +- packages/nest/src/workflow.module.test.ts | 84 ++++------ packages/nest/src/workflow.module.ts | 28 +++- packages/next/src/builder-eager.ts | 2 + packages/next/src/index.test.ts | 27 ++-- packages/next/src/index.ts | 48 ++---- packages/nitro/src/builders.ts | 70 ++++---- packages/nitro/src/index.test.ts | 46 +++++- packages/nitro/src/index.ts | 50 ++++-- packages/nitro/src/vite.ts | 14 +- packages/world-local/src/index.ts | 25 ++- packages/world-postgres/README.md | 2 +- packages/world-postgres/src/index.ts | 77 +++++---- packages/world-vercel/src/index.ts | 38 ++--- packages/world/README.md | 4 +- packages/world/src/index.ts | 8 +- packages/world/src/provider.ts | 15 +- pnpm-lock.yaml | 9 +- 37 files changed, 415 insertions(+), 694 deletions(-) delete mode 100644 packages/builders/src/runtime-config-plugin.ts 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 a408698588..5bfbcfa51e 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 @@ -106,7 +106,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 queue URL to `http://localhost:`, overriding local URL environment variables. 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 diff --git a/docs/content/docs/v5/foundations/configuration.mdx b/docs/content/docs/v5/foundations/configuration.mdx index 5f6a3ed2f4..ca5a476d3b 100644 --- a/docs/content/docs/v5/foundations/configuration.mdx +++ b/docs/content/docs/v5/foundations/configuration.mdx @@ -71,29 +71,13 @@ World packages can expose their own typed helpers: import { defineWorldProvider } from "@workflow/world"; export function hybridWorld(options: HybridOptions) { - return defineWorldProvider({ - create: () => createHybridWorld(options), - }); + return defineWorldProvider(() => createHybridWorld(options)); } ``` -## Environment Values - -```typescript -import { postgresWorld } from "@workflow/world-postgres"; - -postgresWorld({ - connectionString: () => process.env.WORKFLOW_POSTGRES_URL!, -}); -``` - -Provider fields that accept a callback read its value when the World is -created. - ## Different Worlds by Environment -Choose a World inside the provider factory when one build must run against -different infrastructure in different environments: +Choose a World inside the provider factory based on the runtime environment: ```typescript title="workflow.config.ts" lineNumbers import { defineConfig } from "workflow/config"; @@ -101,18 +85,16 @@ import { defineWorldProvider } from "@workflow/world"; import { createLocalWorld } from "@workflow/world-local"; import { createVercelWorld } from "@workflow/world-vercel"; -const world = defineWorldProvider({ - create() { - switch (process.env.NODE_ENV) { - case "production": - return createVercelWorld(); - case "development": - case "test": - return createLocalWorld(); - default: - throw new Error(`Unexpected NODE_ENV: ${process.env.NODE_ENV}`); - } - }, +const world = defineWorldProvider(() => { + switch (process.env.NODE_ENV) { + case "production": + return createVercelWorld(); + case "development": + case "test": + return createLocalWorld(); + default: + throw new Error(`Unexpected NODE_ENV: ${process.env.NODE_ENV}`); + } }); export default defineConfig({ world }); diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 57ad0316be..7327360153 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -34,7 +34,6 @@ import { } from './module-specifier.js'; import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js'; import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js'; -import { createRuntimeConfigPlugin } from './runtime-config-plugin.js'; import { createSwcPlugin } from './swc-esbuild-plugin.js'; import { detectWorkflowPatterns } from './transform-utils.js'; import type { SourcemapMode, WorkflowConfig } from './types.js'; @@ -237,9 +236,19 @@ export abstract class BaseBuilder { } private get runtimeConfigPlugins(): esbuild.Plugin[] { - const workflowConfig = this.config.workflowConfig; - if (!workflowConfig?.found) return []; - return [createRuntimeConfigPlugin(workflowConfig.path)]; + const path = this.config.workflowConfig?.path; + if (!path) return []; + return [ + { + name: 'workflow-runtime-config', + setup(build) { + build.onResolve( + { filter: /^@workflow\/config\/runtime-binding$/ }, + () => ({ path }) + ); + }, + }, + ]; } /** diff --git a/packages/builders/src/constants.test.ts b/packages/builders/src/constants.test.ts index fc8439814c..3c73054d3e 100644 --- a/packages/builders/src/constants.test.ts +++ b/packages/builders/src/constants.test.ts @@ -10,9 +10,7 @@ describe('createWorkflowQueueTrigger', () => { }); it('uses the default workflow topic without a namespace', () => { - expect(createWorkflowQueueTrigger(undefined).topic).toBe( - '__wkf_workflow_*' - ); + expect(createWorkflowQueueTrigger().topic).toBe('__wkf_workflow_*'); }); it('uses an explicit namespace when provided', () => { @@ -24,9 +22,7 @@ describe('createWorkflowQueueTrigger', () => { it('uses WORKFLOW_QUEUE_NAMESPACE when no explicit namespace is provided', () => { process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom'; - expect(createWorkflowQueueTrigger(undefined).topic).toBe( - '__custom_wkf_workflow_*' - ); + expect(createWorkflowQueueTrigger().topic).toBe('__custom_wkf_workflow_*'); }); }); @@ -36,7 +32,7 @@ describe('createWorkflowEntrypointOptionsCode', () => { }); it('omits runtime options without a namespace', () => { - expect(createWorkflowEntrypointOptionsCode(undefined)).toBe(''); + expect(createWorkflowEntrypointOptionsCode()).toBe(''); }); it('inlines an explicit namespace', () => { @@ -48,7 +44,7 @@ describe('createWorkflowEntrypointOptionsCode', () => { it('inlines WORKFLOW_QUEUE_NAMESPACE at build time', () => { process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom'; - expect(createWorkflowEntrypointOptionsCode(undefined)).toBe( + expect(createWorkflowEntrypointOptionsCode()).toBe( ', { namespace: "custom" }' ); }); diff --git a/packages/builders/src/constants.ts b/packages/builders/src/constants.ts index 54ade35b93..44958ef91f 100644 --- a/packages/builders/src/constants.ts +++ b/packages/builders/src/constants.ts @@ -1,21 +1,9 @@ -import { QueueNamespaceSchema } from '@workflow/world'; +import { getQueueTopicPrefix } from '@workflow/world'; function resolveQueueNamespace(namespace: string | undefined) { return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE; } -function getQueueTopicPrefix( - kind: 'workflow' | 'step', - namespace: string | undefined -) { - if (namespace !== undefined) { - QueueNamespaceSchema.parse(namespace); - return `__${namespace}_wkf_${kind}_`; - } - - return `__wkf_${kind}_`; -} - /** * Creates a queue trigger configuration for the workflow handler. * Handles both workflow orchestration and step execution on the same route. @@ -27,13 +15,13 @@ function getQueueTopicPrefix( * * @example * // default: topic = '__wkf_workflow_*' - * createWorkflowQueueTrigger(undefined) + * createWorkflowQueueTrigger() * * @example * // namespaced: topic = '__custom_wkf_workflow_*' * createWorkflowQueueTrigger('custom') */ -export function createWorkflowQueueTrigger(namespace: string | undefined) { +export function createWorkflowQueueTrigger(namespace?: string) { const resolvedNamespace = resolveQueueNamespace(namespace); return { @@ -50,9 +38,7 @@ export function createWorkflowQueueTrigger(namespace: string | undefined) { * calls. The namespace is resolved while building so generated route files do * not need `WORKFLOW_QUEUE_NAMESPACE` at runtime. */ -export function createWorkflowEntrypointOptionsCode( - namespace: string | undefined -) { +export function createWorkflowEntrypointOptionsCode(namespace?: string) { const resolvedNamespace = resolveQueueNamespace(namespace); if (!resolvedNamespace) { @@ -68,4 +54,4 @@ export function createWorkflowEntrypointOptionsCode( /** * Default queue trigger (no namespace). Backward compatible. */ -export const WORKFLOW_QUEUE_TRIGGER = createWorkflowQueueTrigger(undefined); +export const WORKFLOW_QUEUE_TRIGGER = createWorkflowQueueTrigger(); diff --git a/packages/builders/src/resolve-sourcemap.test.ts b/packages/builders/src/resolve-sourcemap.test.ts index e7ec8cbf52..d9ceab7efc 100644 --- a/packages/builders/src/resolve-sourcemap.test.ts +++ b/packages/builders/src/resolve-sourcemap.test.ts @@ -45,7 +45,7 @@ function createBuilder( options.workflowSourcemap === undefined ? undefined : { - found: false, + path: '/tmp/workflow.config.ts', config: { build: { sourcemap: options.workflowSourcemap } }, }, }; diff --git a/packages/builders/src/runtime-config-plugin.ts b/packages/builders/src/runtime-config-plugin.ts deleted file mode 100644 index 79bb727a54..0000000000 --- a/packages/builders/src/runtime-config-plugin.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Plugin } from 'esbuild'; - -export function createRuntimeConfigPlugin(runtimeConfigPath: string): Plugin { - return { - name: 'workflow-runtime-config', - setup(build) { - build.onResolve( - { filter: /^@workflow\/config\/runtime-binding$/ }, - () => ({ - path: runtimeConfigPath, - }) - ); - }, - }; -} diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index 85d8a26a88..f2e28bef11 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -28,7 +28,7 @@ export default class Build extends BaseCommand { description: 'output location for workflow manifest', }), config: Flags.string({ - description: 'path to workflow.config.ts', + description: 'path to a Workflow config file', }), }; diff --git a/packages/cli/src/lib/config/workflow-config.ts b/packages/cli/src/lib/config/workflow-config.ts index 353295f743..8ea0c0cc53 100644 --- a/packages/cli/src/lib/config/workflow-config.ts +++ b/packages/cli/src/lib/config/workflow-config.ts @@ -3,7 +3,7 @@ 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(); @@ -21,7 +21,7 @@ export const getWorkflowConfig = async ( } = {} ): Promise => { const { buildTarget = 'standalone', workflowManifest, configFile } = options; - const workingDir = resolveObservabilityCwd(); + const workingDir = resolveWorkflowCwd(); loadDotEnv({ path: resolve(workingDir, '.env.local'), quiet: true, diff --git a/packages/cli/src/lib/inspect/env.ts b/packages/cli/src/lib/inspect/env.ts index f1c6ea0056..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 = (await 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 = (await 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/package.json b/packages/config/package.json index c4d08e6246..112cd6fa1f 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -50,6 +50,7 @@ }, "dependencies": { "@workflow/world": "workspace:*", + "find-up": "7.0.0", "jiti": "2.7.0", "zod": "catalog:" }, diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 4c8ea79b2a..4639ce1171 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,11 +1,6 @@ -export { - defineWorldProvider, - type ProviderValue, - type WorldProvider, -} from '@workflow/world'; -export type { WorkflowConfigLoader } from './load.js'; export type { SourcemapMode, WorkflowConfig } from './schema.js'; -export { WorkflowConfigSchema } from './schema.js'; +export type WorkflowConfigLoader = + typeof import('./load.js').loadWorkflowConfig; import type { WorkflowConfig } from './schema.js'; diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index 26028f12cd..edded50338 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -1,25 +1,23 @@ -import assert from 'node:assert/strict'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import type { World } from '@workflow/world'; -import { defineWorldProvider } from '@workflow/world'; import { afterEach, describe, expect, it } from 'vitest'; import { loadWorkflowConfig } from './load.js'; import { WorkflowConfigSchema } from './schema.js'; const tempDirs: string[] = []; -function createProject(): string { +function createProject(files: Record): string { const project = mkdtempSync(join(tmpdir(), 'workflow-config-')); - mkdirSync(join(project, '.git')); tempDirs.push(project); - return project; -} -function writeFile(path: string, contents: string): void { - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, contents, 'utf8'); + 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(() => { @@ -30,26 +28,20 @@ afterEach(() => { describe('loadWorkflowConfig', () => { it('loads the nearest TypeScript config without merging parents', async () => { - const project = createProject(); - const app = join(project, 'apps', 'web'); - writeFile( - join(project, 'workflow.config.ts'), - `export default { build: { dirs: ['parent'] } };` - ); - writeFile( - join(app, 'workflow.config.ts'), - `export default { + 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', lazyDiscovery: false } - };` - ); + };`, + }); + const app = join(project, 'apps', 'web'); const loaded = await loadWorkflowConfig({ cwd: app, integration: 'next', }); - assert(loaded.found); expect(loaded.path).toBe(join(app, 'workflow.config.ts')); expect(loaded.config).toEqual({ build: { dirs: ['app'], sourcemap: false }, @@ -58,15 +50,10 @@ describe('loadWorkflowConfig', () => { }); it('rejects multiple config files in one directory', async () => { - const project = createProject(); - writeFile( - join(project, 'workflow.config.ts'), - `export default { build: { dirs: ['typescript'] } };` - ); - writeFile( - join(project, 'workflow.config.mjs'), - `export default { build: { dirs: ['javascript'] } };` - ); + 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' @@ -74,12 +61,12 @@ describe('loadWorkflowConfig', () => { }); it('rejects unsupported filenames instead of silently using a parent', async () => { - const project = createProject(); + const project = createProject({ + 'app/workflow.config.json': JSON.stringify({ + build: { dirs: ['workflows'] }, + }), + }); const app = join(project, 'app'); - writeFile( - join(app, 'workflow.config.json'), - JSON.stringify({ build: { dirs: ['workflows'] } }) - ); await expect(loadWorkflowConfig({ cwd: app })).rejects.toThrow( 'Unsupported Workflow config file' @@ -87,11 +74,9 @@ describe('loadWorkflowConfig', () => { }); it('rejects integration config for another platform', async () => { - const project = createProject(); - writeFile( - join(project, 'workflow.config.ts'), - `export default { integration: { type: 'nest' } };` - ); + const project = createProject({ + 'workflow.config.ts': `export default { integration: { type: 'nest' } };`, + }); await expect( loadWorkflowConfig({ @@ -102,21 +87,17 @@ describe('loadWorkflowConfig', () => { }); it('rejects config functions and unknown keys', async () => { - const project = createProject(); - writeFile( - join(project, 'workflow.config.ts'), - `export default () => ({ build: { dirs: ['workflows'] } });` - ); + 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(); - writeFile( - join(promiseProject, 'workflow.config.ts'), - `export default Promise.resolve({ build: { dirs: ['workflows'] } });` - ); + 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' ); @@ -126,17 +107,6 @@ describe('loadWorkflowConfig', () => { ); }); - it('accepts a typed inert WorldProvider', () => { - const provider = defineWorldProvider({ - create: () => ({}) as World, - }); - - expect(WorkflowConfigSchema.parse({ world: provider })).toEqual({ - world: provider, - }); - expect(() => WorkflowConfigSchema.parse({ world: {} })).toThrow(); - }); - it('rejects empty single-setting sections', () => { expect(() => WorkflowConfigSchema.parse({ queue: {} })).toThrow(); expect(() => diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 17a387f0ee..68359356c8 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -1,13 +1,7 @@ import assert from 'node:assert/strict'; -import { existsSync, readFileSync, statSync } from 'node:fs'; -import { - basename, - dirname, - extname, - isAbsolute, - join, - resolve, -} from 'node:path'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { basename, extname, isAbsolute, join, resolve } from 'node:path'; +import { findUp } from 'find-up'; import { createJiti } from 'jiti'; import { type WorkflowConfig, @@ -17,17 +11,8 @@ import { const WORKFLOW_CONFIG_FILES = [ 'workflow.config.ts', - 'workflow.config.mts', - 'workflow.config.js', 'workflow.config.mjs', -] as const; - -const UNSUPPORTED_WORKFLOW_CONFIG_FILES = [ - 'workflow.config.cjs', - 'workflow.config.cts', - 'workflow.config.json', - 'workflow.config.jsx', - 'workflow.config.tsx', + 'workflow.config.js', ] as const; export type LoadWorkflowConfigOptions = { @@ -36,50 +21,21 @@ export type LoadWorkflowConfigOptions = { integration?: WorkflowIntegrationType; }; -export type LoadedWorkflowConfig = - | { - found: false; - config: WorkflowConfig; - } - | { - found: true; - path: string; - config: WorkflowConfig; - }; - -function isSearchRoot(dir: string): boolean { - if ( - existsSync(join(dir, '.git')) || - existsSync(join(dir, 'pnpm-workspace.yaml')) - ) { - return true; - } - - const packageJsonPath = join(dir, 'package.json'); - if (!existsSync(packageJsonPath)) { - return false; - } - - const packageJson: unknown = JSON.parse( - readFileSync(packageJsonPath, 'utf8') - ); - assert( - packageJson !== null && - typeof packageJson === 'object' && - !Array.isArray(packageJson), - `${packageJsonPath} must contain an object.` - ); - return 'workspaces' in packageJson; -} +export type LoadedWorkflowConfig = { + path: string | undefined; + config: WorkflowConfig; +}; -function discoverWorkflowConfig({ +async function discoverWorkflowConfig({ cwd, configFile, -}: Pick): string | undefined { +}: Pick): Promise< + string | undefined +> { if (configFile) { const path = isAbsolute(configFile) ? configFile : resolve(cwd, configFile); assert( - ['.ts', '.mts', '.js', '.mjs'].includes(extname(path)), + ['.ts', '.mjs', '.js'].includes(extname(path)), `Unsupported Workflow config extension "${extname(path)}".` ); assert( @@ -89,45 +45,35 @@ function discoverWorkflowConfig({ return path; } - let dir = resolve(cwd); - while (true) { - const unsupported = UNSUPPORTED_WORKFLOW_CONFIG_FILES.filter((file) => - existsSync(join(dir, file)) - ); - assert( - unsupported.length === 0, - `Unsupported Workflow config file "${unsupported[0]}".` - ); - - const configs = WORKFLOW_CONFIG_FILES.filter((file) => - existsSync(join(dir, file)) - ); - assert( - configs.length <= 1, - `Multiple Workflow config files found in ${dir}: ${configs.join(', ')}` - ); - if (configs[0]) { - return join(dir, configs[0]); - } - - if (isSearchRoot(dir)) { - return; - } - - const parent = dirname(dir); - if (parent === dir) { - return; - } - dir = parent; - } + 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 = discoverWorkflowConfig(options); + const path = await discoverWorkflowConfig(options); if (!path) { - return { found: false, config: {} }; + return { path, config: {} }; } const configModule = await createJiti(import.meta.url, { @@ -150,7 +96,5 @@ export async function loadWorkflowConfig( `${basename(path)} configures "${config.integration?.type}" but was loaded by "${options.integration}".` ); - return { found: true, path, config }; + return { path, config }; } - -export type WorkflowConfigLoader = typeof loadWorkflowConfig; diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 1e6ba4c915..e19b0a03ed 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -1,4 +1,5 @@ -import { QueueNamespaceSchema, WorldProviderSchema } from '@workflow/world'; +import { WorldProviderSchema } from '@workflow/world/provider.js'; +import { QueueNamespaceSchema } from '@workflow/world/queue.js'; import { z } from 'zod/v4'; const sourcemapSchema = z.union([ diff --git a/packages/core/src/runtime/world-config.test.ts b/packages/core/src/runtime/world-config.test.ts index cb2daa5254..ee3dc10f34 100644 --- a/packages/core/src/runtime/world-config.test.ts +++ b/packages/core/src/runtime/world-config.test.ts @@ -1,160 +1,53 @@ import { setRuntimeWorkflowConfig } from '@workflow/config/runtime'; import type { World } from '@workflow/world'; -import { - defineWorldProvider, - setWorkflowQueueNamespace, -} from '@workflow/world'; +import { defineWorldProvider } from '@workflow/world'; +import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - closeWorld, - createWorld, - getWorld, - getWorldHandlers, - setWorld, -} from './world.js'; +import { closeWorld, getWorld, getWorldHandlers } from './world.js'; -const originalTargetWorld = process.env.WORKFLOW_TARGET_WORLD; -const originalNodeEnv = process.env.NODE_ENV; - -function mockWorld(overrides: Partial = {}): World { - return { - createQueueHandler: vi.fn(), - ...overrides, - } as unknown as World; -} +const targetWorld = process.env.WORKFLOW_TARGET_WORLD; afterEach(async () => { await closeWorld(); - setWorld(undefined); setRuntimeWorkflowConfig(undefined); setWorkflowQueueNamespace(undefined); - vi.restoreAllMocks(); - if (originalTargetWorld === undefined) { + if (targetWorld === undefined) { delete process.env.WORKFLOW_TARGET_WORLD; } else { - process.env.WORKFLOW_TARGET_WORLD = originalTargetWorld; - } - if (originalNodeEnv === undefined) { - delete process.env.NODE_ENV; - } else { - process.env.NODE_ENV = originalNodeEnv; + process.env.WORKFLOW_TARGET_WORLD = targetWorld; } }); -describe('configured World lifecycle', () => { - it('creates and starts one shared World at runtime', async () => { +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 = mockWorld({ start, close, specVersion: 4 }); - const create = vi.fn(async () => world); + const world = { + createQueueHandler: vi.fn(), + specVersion: 4, + start, + close, + } as unknown as World; + const create = vi.fn(() => world); + setRuntimeWorkflowConfig({ - world: defineWorldProvider({ - create, - }), - queue: { namespace: 'myapp' }, + world: defineWorldProvider(create), + queue: { namespace: 'app' }, }); - const [resolvedWorld, handlers] = await Promise.all([ + expect(create).not.toHaveBeenCalled(); + const [resolved, handlers] = await Promise.all([ getWorld(), getWorldHandlers(), ]); - expect(resolvedWorld).toBe(world); + expect(resolved).toBe(world); expect(handlers.specVersion).toBe(4); expect(create).toHaveBeenCalledOnce(); - expect(create).toHaveBeenCalledWith(); expect(start).toHaveBeenCalledOnce(); await closeWorld(); expect(close).toHaveBeenCalledOnce(); }); - - it('does not start a fresh World returned by createWorld()', async () => { - delete process.env.WORKFLOW_TARGET_WORLD; - const start = vi.fn(async () => {}); - setRuntimeWorkflowConfig({ - world: defineWorldProvider({ - create: () => mockWorld({ start }), - }), - }); - - await createWorld(); - - expect(start).not.toHaveBeenCalled(); - }); - - it('clears a failed provider promise so the next call can retry', async () => { - delete process.env.WORKFLOW_TARGET_WORLD; - const world = mockWorld(); - const create = vi - .fn<() => Promise>() - .mockRejectedValueOnce(new Error('not ready')) - .mockResolvedValueOnce(world); - setRuntimeWorkflowConfig({ - world: defineWorldProvider({ - create, - }), - }); - - await expect(getWorld()).rejects.toThrow('not ready'); - await expect(getWorld()).resolves.toBe(world); - expect(create).toHaveBeenCalledTimes(2); - }); - - it('does not instantiate a World during cleanup', async () => { - const create = vi.fn(() => mockWorld()); - setRuntimeWorkflowConfig({ - world: defineWorldProvider({ - create, - }), - }); - - await closeWorld(); - - expect(create).not.toHaveBeenCalled(); - }); - - it('prefers WORKFLOW_TARGET_WORLD over configured providers', async () => { - process.env.WORKFLOW_TARGET_WORLD = 'local'; - const create = vi.fn(() => mockWorld()); - setRuntimeWorkflowConfig({ - world: defineWorldProvider({ - create, - }), - }); - - await getWorld(); - - expect(create).not.toHaveBeenCalled(); - }); - - it('selects a World when the provider factory runs', async () => { - delete process.env.WORKFLOW_TARGET_WORLD; - const developmentWorld = mockWorld(); - const productionWorld = mockWorld(); - const create = vi.fn(() => { - switch (process.env.NODE_ENV) { - case 'development': - return developmentWorld; - case 'production': - return productionWorld; - default: - throw new Error(`Unexpected NODE_ENV: ${process.env.NODE_ENV}`); - } - }); - setRuntimeWorkflowConfig({ - world: defineWorldProvider({ - create, - }), - }); - - process.env.NODE_ENV = 'development'; - await expect(getWorld()).resolves.toBe(developmentWorld); - await closeWorld(); - - process.env.NODE_ENV = 'production'; - await expect(getWorld()).resolves.toBe(productionWorld); - expect(create).toHaveBeenCalledTimes(2); - }); }); diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index 769d1af535..7ca2fc047a 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -1,6 +1,5 @@ import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; -import { type WorkflowConfig, WorkflowConfigSchema } from '@workflow/config'; import { getRuntimeWorkflowConfig } from '@workflow/config/runtime'; import boundWorkflowConfig from '@workflow/config/runtime-binding'; import { @@ -8,7 +7,7 @@ import { resolveWorkflowTargetWorld, } from '@workflow/utils'; import type { World } from '@workflow/world'; -import { setWorkflowQueueNamespace } from '@workflow/world'; +import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; import { createLocalWorld } from '@workflow/world-local'; import { createVercelWorld } from '@workflow/world-vercel'; @@ -26,12 +25,10 @@ function getRuntimeRequire() { const WorldCache = Symbol.for('@workflow/world//cache'); const WorldCachePromise = Symbol.for('@workflow/world//cachePromise'); -const RuntimeConfigPromise = Symbol.for('@workflow/config//cachePromise'); const globalSymbols: typeof globalThis & { [WorldCache]?: World; [WorldCachePromise]?: Promise; - [RuntimeConfigPromise]?: Promise; } = globalThis; // Dynamic import for custom world modules. Uses a standard import() @@ -76,31 +73,6 @@ function resolveModulePath(specifier: string): string { * vars should call createVercelWorld() directly with an explicit config and * use setWorld() to inject the instance. */ -async function loadRuntimeWorkflowConfig(): Promise { - if (boundWorkflowConfig !== undefined) { - return WorkflowConfigSchema.parse(boundWorkflowConfig); - } - - const installedConfig = getRuntimeWorkflowConfig(); - if (installedConfig !== undefined) { - return WorkflowConfigSchema.parse(installedConfig); - } - - if (!globalSymbols[RuntimeConfigPromise]) { - globalSymbols[RuntimeConfigPromise] = import('@workflow/config/load') - .then(({ loadWorkflowConfig }) => - loadWorkflowConfig({ cwd: process.cwd() }) - ) - .then(({ config }) => config) - .catch((error) => { - globalSymbols[RuntimeConfigPromise] = undefined; - throw error; - }); - } - - return globalSymbols[RuntimeConfigPromise]; -} - async function createLegacyWorld(): Promise { const targetWorld = resolveWorkflowTargetWorld(); @@ -161,7 +133,7 @@ type ResolvedWorld = | { type: 'legacy'; world: World }; async function resolveWorld(): Promise { - const config = await loadRuntimeWorkflowConfig(); + const config = boundWorkflowConfig ?? getRuntimeWorkflowConfig() ?? {}; setWorkflowQueueNamespace(config.queue?.namespace); if (process.env.WORKFLOW_TARGET_WORLD) { diff --git a/packages/nest/package.json b/packages/nest/package.json index 93be84ab38..d498e9aff2 100644 --- a/packages/nest/package.json +++ b/packages/nest/package.json @@ -35,6 +35,7 @@ "@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 43c4e07ec9..d03c0be5f2 100644 --- a/packages/nest/src/builder.ts +++ b/packages/nest/src/builder.ts @@ -76,10 +76,10 @@ export class NestLocalBuilder extends BaseBuilder { config?.integration?.type === 'nest' ? config.integration : undefined; const build = config?.build; const workingDir = options.workingDir ?? process.cwd(); - const outDir = - options.outDir ?? - integration?.outDir ?? - join(workingDir, '.nestjs/workflow'); + const outDir = resolve( + workingDir, + options.outDir ?? integration?.outDir ?? '.nestjs/workflow' + ); const dirs = options.dirs ?? build?.dirs ?? ['src']; const projectRoot = options.projectRoot ?? build?.projectRoot; super({ 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 index 8290307a39..7e47e90a50 100644 --- a/packages/nest/src/workflow.module.test.ts +++ b/packages/nest/src/workflow.module.test.ts @@ -1,84 +1,54 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { Module } from '@nestjs/common'; -import { NestFactory } from '@nestjs/core'; +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 tempDirs: string[] = []; +const projects: string[] = []; afterEach(() => { vi.restoreAllMocks(); - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); + for (const project of projects.splice(0)) { + rmSync(project, { recursive: true, force: true }); } }); -describe('WorkflowModule workflow.config.ts', () => { - it('loads Nest and generic build settings before creating the builder', async () => { +describe('WorkflowModule', () => { + it('loads workflow.config.ts before building', async () => { const project = mkdtempSync(join(tmpdir(), 'workflow-nest-config-')); - tempDirs.push(project); - mkdirSync(join(project, '.git')); + projects.push(project); writeFileSync( join(project, 'workflow.config.ts'), `export default { - build: { - dirs: ['src/jobs'], - projectRoot: '..', - externalPackages: ['sharp'], - sourcemap: false, - manifest: { output: 'workflow-manifest.json' } + world: { + type: 'world-provider', + create: () => { throw new Error('must stay lazy'); } }, - queue: { namespace: 'myapp' }, - integration: { - type: 'nest', - moduleType: 'commonjs', - outDir: '.generated/workflow', - distDir: 'build', - watch: true - } + build: { dirs: ['src/jobs'], sourcemap: false }, + integration: { type: 'nest', outDir: '.generated/workflow' } };` ); let builder: NestLocalBuilder | undefined; - let builderConfig: Record | undefined; - vi.spyOn(NestLocalBuilder.prototype, 'build').mockImplementation( - async function (this: NestLocalBuilder) { + const build = vi + .spyOn(NestLocalBuilder.prototype, 'build') + .mockImplementation(async function (this: NestLocalBuilder) { builder = this; - builderConfig = (this as unknown as { config: Record }) - .config; - } - ); + }); + const module = new WorkflowModule({ workingDir: project }); - @Module({ - imports: [WorkflowModule.forRoot({ workingDir: project })], - }) - class AppModule {} + await module.onModuleInit(); - const app = await NestFactory.createApplicationContext(AppModule, { - logger: false, + expect(build).toHaveBeenCalledOnce(); + expect(builder?.outDir).toBe(resolve(project, '.generated/workflow')); + expect(getRuntimeWorkflowConfig()).toMatchObject({ + build: { dirs: ['src/jobs'], sourcemap: false }, + integration: { type: 'nest' }, }); - expect(builderConfig).toMatchObject({ - dirs: ['src/jobs'], - workingDir: project, - projectRoot: resolve(project, '..'), - externalPackages: ['sharp'], - watch: true, - workflowConfig: { - found: true, - path: join(project, 'workflow.config.ts'), - config: { - build: { - manifest: { output: 'workflow-manifest.json' }, - }, - queue: { namespace: 'myapp' }, - }, - }, - }); - expect(builder?.outDir).toBe('.generated/workflow'); - await app.close(); + await module.onModuleDestroy(); + expect(getRuntimeWorkflowConfig()).toBeUndefined(); }); }); diff --git a/packages/nest/src/workflow.module.ts b/packages/nest/src/workflow.module.ts index 55462d23ad..a47a226de7 100644 --- a/packages/nest/src/workflow.module.ts +++ b/packages/nest/src/workflow.module.ts @@ -2,10 +2,13 @@ import { type DynamicModule, Inject, Module, + type OnModuleDestroy, type OnModuleInit, } from '@nestjs/common'; import { createBuildQueue } from '@workflow/builders'; import { 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, @@ -25,7 +28,7 @@ export interface WorkflowModuleOptions extends NestBuilderOptions { * Builds workflow bundles on module initialization and registers the workflow controller. */ @Module({}) -export class WorkflowModule implements OnModuleInit { +export class WorkflowModule implements OnModuleInit, OnModuleDestroy { private static buildQueue = createBuildQueue(); constructor( @@ -65,19 +68,28 @@ export class WorkflowModule implements OnModuleInit { cwd: workingDir, integration: 'nest', }); - const integration = workflowConfig.config.integration; + const config = workflowConfig.config; + const integration = + config.integration?.type === 'nest' ? config.integration : undefined; const builder = new NestLocalBuilder({ ...this.options, workflowConfig, }); - configureWorkflowController(builder.outDir); - if ( - this.options.skipBuild ?? - (integration?.type === 'nest' ? integration.skipBuild : false) - ) - return; + setRuntimeWorkflowConfig(config); + + 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() { + await closeWorld(); + setRuntimeWorkflowConfig(undefined); + } } diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 3d781af014..fdf02418d6 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -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( '\\', diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 5176af7d26..f773264406 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -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,29 +221,36 @@ describe('withWorkflow builder config', () => { expect(webpackConfig?.externals).toEqual([{ react: 'commonjs react' }]); }); - it('lets an explicit local port override PORT', async () => { + it('applies explicit local options before loading Next config', async () => { process.env.PORT = '3000'; + process.env.WORKFLOW_LOCAL_BASE_URL = 'http://localhost:9876'; + let observedBaseUrl: string | undefined; const config = withWorkflow( - {}, + async () => { + observedBaseUrl = process.env.WORKFLOW_LOCAL_BASE_URL; + return {}; + }, { workflows: { - local: { port: 4000 }, + local: { port: 4321 }, }, } ); await config('phase-production-build', { defaultConfig: {} }); - expect(process.env.PORT).toBe('4000'); + expect(process.env.PORT).toBe('4321'); + expect(process.env.WORKFLOW_LOCAL_BASE_URL).toBe('http://localhost:4321'); + expect(observedBaseUrl).toBe('http://localhost:4321'); }); it('prefers environment variables over workflow.config.ts', async () => { const projectDir = mkdtempSync(join(realTmpDir, 'workflow-next-config-')); process.chdir(projectDir); - mkdirSync(join(projectDir, '.git')); writeFile( join(projectDir, 'workflow.config.ts'), `export default { + build: { projectRoot: '../configured-root' }, integration: { type: 'next', local: { port: 4321 } @@ -251,10 +260,11 @@ describe('withWorkflow builder config', () => { process.env.PORT = '9876'; try { - const config = withWorkflow({}); + const config = withWorkflow({ outputFileTracingRoot: '/explicit-root' }); await config('phase-production-build', { defaultConfig: {} }); expect(process.env.PORT).toBe('9876'); + expect(builderConfigs[0]?.projectRoot).toBe('/explicit-root'); } finally { process.chdir(originalCwd); rmSync(projectDir, { recursive: true, force: true }); @@ -263,7 +273,6 @@ describe('withWorkflow builder config', () => { it('applies workflow.config.ts to the Next builder and runtime binding', async () => { const projectDir = mkdtempSync(join(realTmpDir, 'workflow-next-config-')); process.chdir(projectDir); - mkdirSync(join(projectDir, '.git')); writeFile( join(projectDir, 'workflow.config.ts'), `const world = { @@ -302,7 +311,6 @@ export default { dirs: ['jobs'], projectRoot: resolve(projectDir, '../repo-root'), workflowConfig: { - found: true, path: join(projectDir, 'workflow.config.ts'), config: { build: { @@ -326,9 +334,6 @@ export default { ).toBe( `./${relative(turbopackRoot, join(projectDir, 'workflow.config.ts'))}` ); - expect(resolvedConfig.outputFileTracingIncludes?.['/*']).toContain( - 'workflow.config.ts' - ); } finally { process.chdir(originalCwd); rmSync(projectDir, { recursive: true, force: true }); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 26411abe66..ec9fedd1b1 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -29,16 +29,6 @@ const workflowSerdeComputedPropertyPattern = const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']); const warnedAutoRemovedServerExternalPackages = new Set(); -async function loadWorkflowConfigForNext() { - const { loadWorkflowConfig } = require('@workflow/config/load') as { - loadWorkflowConfig: WorkflowConfigLoader; - }; - return loadWorkflowConfig({ - cwd: process.cwd(), - integration: 'next', - }); -} - interface WorkflowPatternMatch { hasUseWorkflow: boolean; hasUseStep: boolean; @@ -362,21 +352,9 @@ export function withWorkflow( } const loaderPath = require.resolve('./loader'); - let nextConfig: NextConfig; - - if (typeof nextConfigOrFn === 'function') { - nextConfig = await nextConfigOrFn(phase, ctx); - } else { - nextConfig = nextConfigOrFn; - } - // shallow clone to avoid read-only on top-level - nextConfig = Object.assign({}, nextConfig); - const loadedWorkflowConfig = await loadWorkflowConfigForNext(); const workflowConfig = loadedWorkflowConfig.config; - const runtimeConfigPath = loadedWorkflowConfig.found - ? loadedWorkflowConfig.path - : undefined; + const runtimeConfigPath = loadedWorkflowConfig.path; const nextIntegration = workflowConfig.integration?.type === 'next' ? workflowConfig.integration @@ -389,6 +367,7 @@ export function withWorkflow( } 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 @@ -399,6 +378,13 @@ export function withWorkflow( 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 || []), @@ -482,22 +468,6 @@ export function withWorkflow( ? runtimeConfigRequest : `./${runtimeConfigRequest}`, }; - - const tracedConfigPath = relative( - process.cwd(), - runtimeConfigPath - ).replaceAll('\\', '/'); - const existingTracingIncludes = - nextConfig.outputFileTracingIncludes || {}; - nextConfig.outputFileTracingIncludes = { - ...existingTracingIncludes, - '/*': [ - ...new Set([ - ...(existingTracingIncludes['/*'] || []), - tracedConfigPath, - ]), - ], - }; } const existingRules = nextConfig.turbopack.rules as any; const nextVersion = resolveNextVersion(process.cwd()); diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index 405da975d4..a5c2a3f6aa 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -19,40 +19,45 @@ import { join, resolve } from 'pathe'; * returns undefined. */ type NitroV2ExternalsOptions = { externals?: { external?: unknown[] } }; -function getNitroStringExternals(nitro: Nitro): string[] | undefined { +function getNitroStringExternals(nitro: Nitro): string[] { const external = (nitro.options as NitroV2ExternalsOptions).externals ?.external; - const strings = external?.filter( - (entry): entry is string => typeof entry === 'string' + return ( + external?.filter((entry): entry is string => typeof entry === 'string') ?? + [] ); - return strings && strings.length > 0 ? strings : undefined; } -function mergeExternalPackages( - ...groups: Array -): string[] | undefined { - const packages = [...new Set(groups.flatMap((group) => group ?? []))]; - return packages.length > 0 ? packages : undefined; +function createNitroBuilderConfig( + nitro: Nitro, + loadedConfig: LoadedWorkflowConfig +) { + const build = loadedConfig.config.build; + const externalPackages = [ + ...new Set([ + ...(build?.externalPackages ?? []), + ...getNitroStringExternals(nitro), + ]), + ]; + + 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, loadedConfig: LoadedWorkflowConfig) { - const buildConfig = loadedConfig.config.build; super({ - ...createBaseBuilderConfig({ - workingDir: nitro.options.rootDir, - dirs: nitro.options.workflow?.dirs ?? buildConfig?.dirs ?? ['.'], - projectRoot: buildConfig?.projectRoot - ? resolve(nitro.options.rootDir, buildConfig.projectRoot) - : undefined, - runtime: nitro.options.workflow?.runtime, - sourcemap: nitro.options.workflow?.sourcemap, - externalPackages: mergeExternalPackages( - buildConfig?.externalPackages, - getNitroStringExternals(nitro) - ), - workflowConfig: loadedConfig, - }), + ...createNitroBuilderConfig(nitro, loadedConfig), + runtime: nitro.options.workflow?.runtime, buildTarget: 'vercel-build-output-api', }); } @@ -73,22 +78,9 @@ export class LocalBuilder extends BaseBuilder { #outDir: string; constructor(nitro: Nitro, loadedConfig: LoadedWorkflowConfig) { const outDir = join(nitro.options.buildDir, 'workflow'); - const buildConfig = loadedConfig.config.build; super({ - ...createBaseBuilderConfig({ - workingDir: nitro.options.rootDir, - watch: nitro.options.dev, - dirs: nitro.options.workflow?.dirs ?? buildConfig?.dirs ?? ['.'], - projectRoot: buildConfig?.projectRoot - ? resolve(nitro.options.rootDir, buildConfig.projectRoot) - : undefined, - sourcemap: nitro.options.workflow?.sourcemap, - externalPackages: mergeExternalPackages( - buildConfig?.externalPackages, - getNitroStringExternals(nitro) - ), - workflowConfig: loadedConfig, - }), + ...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 6690edad42..70371cef02 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders'; @@ -25,7 +25,7 @@ function createNitroStub({ dev = false, preset = 'node-server', workflow = {}, - rootDir = '/tmp/project', + rootDir = process.cwd(), externals, vercel, }: StubOptions) { @@ -113,12 +113,49 @@ 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 = mkdtempSync(join(tmpdir(), 'workflow-nitro-config-')); + writeFileSync(join(project, 'workflow.config.ts'), 'export default {};'); + + try { + 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).not.toContain('@workflow/config/runtime";'); + expect(source).toContain(assignment); + expect(source.indexOf(assignment)).toBeLessThan( + source.indexOf('import(currentImportPath)') + ); + } finally { + rmSync(project, { recursive: true, force: true }); + } + }); }); describe('@workflow/nitro workflow.config.ts', () => { it('applies typed Nitro settings and a namespaced queue trigger', async () => { const project = mkdtempSync(join(tmpdir(), 'workflow-nitro-config-')); - mkdirSync(join(project, '.git')); writeFileSync( join(project, 'workflow.config.ts'), `export default { @@ -175,7 +212,6 @@ describe('@workflow/nitro workflow.config.ts', () => { it('prefers environment variables over workflow.config.ts', async () => { const project = mkdtempSync(join(tmpdir(), 'workflow-nitro-config-')); - mkdirSync(join(project, '.git')); writeFileSync( join(project, 'workflow.config.ts'), `export default { @@ -422,7 +458,7 @@ describe('@workflow/nitro isNitroV2 detection', () => { }); describe('@workflow/nitro externals forwarding', () => { - const loadedConfig = { found: false, config: {} } as const; + const loadedConfig = { path: undefined, config: {} } as const; for (const [label, Builder] of [ ['VercelBuilder', VercelBuilder], diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index ea9d7e5a65..8afdeb7ede 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -27,17 +27,15 @@ function isNitroV2(nitro: Nitro): boolean { return !nitro.routing; } -export default { +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.found - ? loadedWorkflowConfig.path - : undefined; + const runtimeConfigPath = loadedWorkflowConfig.path; const nitroIntegration = workflowConfig.integration?.type === 'nitro' ? workflowConfig.integration @@ -49,7 +47,6 @@ export default { nitro.options.workflow?.typescriptPlugin ?? nitroIntegration?.typescriptPlugin, runtime: nitro.options.workflow?.runtime ?? nitroIntegration?.runtime, - sourcemap: nitro.options.workflow?.sourcemap, }; const publicManifest = process.env.WORKFLOW_PUBLIC_MANIFEST === undefined @@ -215,11 +212,11 @@ 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, loadedWorkflowConfig); + 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 @@ -237,7 +234,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 @@ -255,7 +252,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. @@ -264,7 +262,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 @@ -326,8 +325,21 @@ export default { } addManifestHandler(nitro); } + + return localBuilder; } }, +}; + +export function setupNitro(nitro: Nitro): Promise { + return nitroModule.setup(nitro); +} + +export default { + name: nitroModule.name, + async setup(nitro: Nitro) { + await nitroModule.setup(nitro); + }, } satisfies NitroModule; const DASHBOARD_VIRTUAL_ID = '#workflow/dashboard-handler'; @@ -398,7 +410,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}`, @@ -408,6 +425,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. @@ -417,6 +441,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 = ""; @@ -440,6 +465,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/vite.ts b/packages/nitro/src/vite.ts index 46d9d70e06..a1f4946e23 100644 --- a/packages/nitro/src/vite.ts +++ b/packages/nitro/src/vite.ts @@ -1,14 +1,13 @@ import { createBuildQueue } from '@workflow/builders'; -import { loadWorkflowConfig } from '@workflow/config/load'; import { workflowTransformPlugin } from '@workflow/rollup'; import { workflowHotUpdatePlugin } from '@workflow/vite'; 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 { setupNitro } from './index.js'; export function workflow(options?: ModuleOptions): Plugin[] { let builder: LocalBuilder | undefined; @@ -42,14 +41,7 @@ export function workflow(options?: ModuleOptions): Plugin[] { ...options, _vite: true, }; - await nitroModule.setup(nitro); - if (nitro.options.dev) { - const loadedWorkflowConfig = await loadWorkflowConfig({ - cwd: nitro.options.rootDir, - integration: 'nitro', - }); - builder = new LocalBuilder(nitro, loadedWorkflowConfig); - } + builder = await setupNitro(nitro); }, }, // NOTE: This is a workaround because Nitro passes the 404 requests to the dev server to handle. diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts index a6eacd3073..aab6bb89ae 100644 --- a/packages/world-local/src/index.ts +++ b/packages/world-local/src/index.ts @@ -178,17 +178,16 @@ export function createLocalWorld(args?: Partial): LocalWorld { } /** Creates a local provider for workflow.config.ts. */ -export function localWorld(args?: Partial): WorldProvider { - return defineWorldProvider({ - create: () => - createLocalWorld({ - ...args, - dataDir: process.env.WORKFLOW_LOCAL_DATA_DIR ?? args?.dataDir, - baseUrl: - process.env.WORKFLOW_LOCAL_BASE_URL ?? - (process.env.PORT - ? `http://localhost:${process.env.PORT}` - : args?.baseUrl), - }), - }); +export function localWorld(args: Partial = {}): WorldProvider { + return defineWorldProvider(() => + createLocalWorld({ + ...args, + dataDir: process.env.WORKFLOW_LOCAL_DATA_DIR ?? args.dataDir, + baseUrl: + process.env.WORKFLOW_LOCAL_BASE_URL ?? + (process.env.PORT + ? `http://localhost:${process.env.PORT}` + : args.baseUrl), + }) + ); } diff --git a/packages/world-postgres/README.md b/packages/world-postgres/README.md index 2b83feeb9a..d71f964230 100644 --- a/packages/world-postgres/README.md +++ b/packages/world-postgres/README.md @@ -33,7 +33,7 @@ export WORKFLOW_POSTGRES_URL="postgres://username:password@localhost:5432/databa # Optional: Job prefix for queue operations export WORKFLOW_POSTGRES_JOB_PREFIX="myapp" -# Optional: Worker concurrency (default: 10) +# Optional: Worker concurrency (default: 50) export WORKFLOW_POSTGRES_WORKER_CONCURRENCY="10" # Optional: Internal pg.Pool max size (default: 10) diff --git a/packages/world-postgres/src/index.ts b/packages/world-postgres/src/index.ts index 488bc6fca8..72c410ed8a 100644 --- a/packages/world-postgres/src/index.ts +++ b/packages/world-postgres/src/index.ts @@ -1,13 +1,7 @@ -import type { - ProviderValue, - Storage, - World, - WorldProvider, -} from '@workflow/world'; +import type { Storage, World, WorldProvider } from '@workflow/world'; import { defineWorldProvider, reenqueueActiveRuns, - resolveProviderValue, SPEC_VERSION_CURRENT, } from '@workflow/world'; import { Pool } from 'pg'; @@ -49,34 +43,29 @@ function getQueueConcurrencyFromEnv(): number | undefined { return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } -function getDefaultQueueConcurrency(): number { - return getQueueConcurrencyFromEnv() ?? 50; -} - export function createWorld( config: PostgresWorldConfig = { connectionString: process.env.WORKFLOW_POSTGRES_URL || 'postgres://world:world@localhost:5432/world', + jobPrefix: process.env.WORKFLOW_POSTGRES_JOB_PREFIX, + queueConcurrency: + parseInt(process.env.WORKFLOW_POSTGRES_WORKER_CONCURRENCY || '50', 10) || + 50, } ): World & { start(): Promise } { - const resolvedConfig = { - ...config, - jobPrefix: config.jobPrefix ?? process.env.WORKFLOW_POSTGRES_JOB_PREFIX, - queueConcurrency: config.queueConcurrency ?? getDefaultQueueConcurrency(), - }; - const maxPoolSize = resolvedConfig.maxPoolSize ?? getDefaultMaxPoolSize(); + const maxPoolSize = config.maxPoolSize ?? getDefaultMaxPoolSize(); const pool = - resolvedConfig.pool || + config.pool || new Pool({ connectionString: - resolvedConfig.connectionString || + config.connectionString || 'postgres://world:world@localhost:5432/world', ...(maxPoolSize !== undefined ? { max: maxPoolSize } : {}), }); const drizzle = createClient(pool); - const queue = createQueue(resolvedConfig, pool); + const queue = createQueue(config, pool); const storage = createStorage(drizzle); const streamer = createStreamer(pool, drizzle); @@ -85,8 +74,8 @@ export function createWorld( ...storage, ...streamer, ...queue, - ...(resolvedConfig.streamFlushIntervalMs !== undefined && { - streamFlushIntervalMs: resolvedConfig.streamFlushIntervalMs, + ...(config.streamFlushIntervalMs !== undefined && { + streamFlushIntervalMs: config.streamFlushIntervalMs, }), async start() { await queue.start(); @@ -95,38 +84,44 @@ export function createWorld( async close() { await streamer.close(); await queue.close(); - if (pool !== resolvedConfig.pool) { + if (pool !== config.pool) { await pool.end(); } }, }; } -export type PostgresWorldProviderConfig = Omit< - Extract, - 'connectionString' | 'namespace' | 'pool' +type PostgresConnectionConfig = Extract< + PostgresWorldConfig, + { connectionString: string } +>; + +export type PostgresWorldProviderConfig = Pick< + PostgresConnectionConfig, + 'jobPrefix' | 'queueConcurrency' | 'maxPoolSize' | 'streamFlushIntervalMs' > & { - connectionString?: ProviderValue; + connectionString?: string | (() => string); }; /** Creates a PostgreSQL provider for workflow.config.ts. */ export function postgresWorld( config: PostgresWorldProviderConfig = {} ): WorldProvider { - return defineWorldProvider({ - create: () => - createWorld({ - connectionString: - process.env.WORKFLOW_POSTGRES_URL || - (config.connectionString === undefined - ? 'postgres://world:world@localhost:5432/world' - : resolveProviderValue(config.connectionString)), - jobPrefix: process.env.WORKFLOW_POSTGRES_JOB_PREFIX ?? config.jobPrefix, - queueConcurrency: - getQueueConcurrencyFromEnv() ?? config.queueConcurrency, - maxPoolSize: getDefaultMaxPoolSize() ?? config.maxPoolSize, - streamFlushIntervalMs: config.streamFlushIntervalMs, - }), + return defineWorldProvider(() => { + const connectionString = + process.env.WORKFLOW_POSTGRES_URL ?? + (typeof config.connectionString === 'function' + ? config.connectionString() + : config.connectionString); + + return createWorld({ + connectionString: + connectionString ?? 'postgres://world:world@localhost:5432/world', + jobPrefix: process.env.WORKFLOW_POSTGRES_JOB_PREFIX ?? config.jobPrefix, + queueConcurrency: getQueueConcurrencyFromEnv() ?? config.queueConcurrency, + maxPoolSize: getDefaultMaxPoolSize() ?? config.maxPoolSize, + streamFlushIntervalMs: config.streamFlushIntervalMs, + }); }); } diff --git a/packages/world-vercel/src/index.ts b/packages/world-vercel/src/index.ts index b1513faa15..ad3f4bb61e 100644 --- a/packages/world-vercel/src/index.ts +++ b/packages/world-vercel/src/index.ts @@ -1,7 +1,6 @@ -import type { ProviderValue, World, WorldProvider } from '@workflow/world'; +import type { World, WorldProvider } from '@workflow/world'; import { defineWorldProvider, - resolveProviderValue, SPEC_VERSION_SUPPORTS_COMPRESSION, } from '@workflow/world'; import { createGetEncryptionKeyForRun } from './encryption.js'; @@ -56,30 +55,27 @@ export function createVercelWorld(config?: APIConfig): World { }; } -export type VercelWorldProviderConfig = Omit< - APIConfig, - 'token' | 'dispatcher' -> & { - token?: ProviderValue; - dispatcher?: ProviderValue; +export type VercelWorldProviderConfig = Omit & { + token?: string | (() => string | undefined); }; /** Creates a Vercel provider for workflow.config.ts. */ export function vercelWorld( config: VercelWorldProviderConfig = {} ): WorldProvider { - return defineWorldProvider({ - create: () => - createVercelWorld({ - ...config, - token: - config.token === undefined - ? undefined - : resolveProviderValue(config.token), - dispatcher: - config.dispatcher === undefined - ? undefined - : resolveProviderValue(config.dispatcher), - }), + return defineWorldProvider(() => { + const token = + process.env.VERCEL_TOKEN ?? + (typeof config.token === 'function' ? config.token() : config.token); + + return createVercelWorld({ + ...config, + token, + projectConfig: config.projectConfig && { + ...config.projectConfig, + projectId: + process.env.VERCEL_PROJECT_ID ?? config.projectConfig.projectId, + }, + }); }); } diff --git a/packages/world/README.md b/packages/world/README.md index b32032236d..4ce1ae14f0 100644 --- a/packages/world/README.md +++ b/packages/world/README.md @@ -14,9 +14,7 @@ Custom World packages can expose a typed helper with `defineWorldProvider()`: import { defineWorldProvider } from '@workflow/world'; export function hybridWorld(options: HybridOptions) { - return defineWorldProvider({ - create: () => createHybridWorld(options), - }); + return defineWorldProvider(() => createHybridWorld(options)); } ``` diff --git a/packages/world/src/index.ts b/packages/world/src/index.ts index e1c6b78832..679069cb15 100644 --- a/packages/world/src/index.ts +++ b/packages/world/src/index.ts @@ -25,11 +25,7 @@ export type * from './hooks.js'; export { HookSchema } from './hooks.js'; export type * from './interfaces.js'; export type * from './provider.js'; -export { - defineWorldProvider, - resolveProviderValue, - WorldProviderSchema, -} from './provider.js'; +export { defineWorldProvider } from './provider.js'; export type * from './queue.js'; export { getQueuePrefixKind, @@ -37,13 +33,11 @@ export { HealthCheckPayloadSchema, MessageId, parseQueueName, - QueueNamespaceSchema, QueuePayloadSchema, QueuePrefix, RunInputSchema, resolveQueueNamespace, StepInvokePayloadSchema, - setWorkflowQueueNamespace, ValidQueueName, WorkflowInvokePayloadSchema, } from './queue.js'; diff --git a/packages/world/src/provider.ts b/packages/world/src/provider.ts index 7d9855ad11..ae8a6c0961 100644 --- a/packages/world/src/provider.ts +++ b/packages/world/src/provider.ts @@ -1,8 +1,6 @@ import { z } from 'zod/v4'; import type { World } from './interfaces.js'; -export type ProviderValue = T | (() => T); - type WorldFactory = () => World | Promise; export const WorldProviderSchema = z.strictObject({ @@ -12,15 +10,6 @@ export const WorldProviderSchema = z.strictObject({ export type WorldProvider = z.infer; -export function defineWorldProvider( - provider: Omit -): WorldProvider { - return WorldProviderSchema.parse({ - type: 'world-provider', - ...provider, - }); -} - -export function resolveProviderValue(value: ProviderValue): T { - return typeof value === 'function' ? (value as () => T)() : value; +export function defineWorldProvider(create: WorldFactory): WorldProvider { + return { type: 'world-provider', create }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbededa66e..6fd3bebe1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -561,6 +561,9 @@ importers: '@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 @@ -763,6 +766,9 @@ importers: '@workflow/config': specifier: workspace:* version: link:../config + '@workflow/core': + specifier: workspace:* + version: link:../core '@workflow/swc-plugin': specifier: workspace:* version: link:../swc-plugin-workflow @@ -16561,7 +16567,6 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} - deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -36430,7 +36435,7 @@ snapshots: vitest@4.0.18(@opentelemetry/api@1.9.0)(@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): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) + '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 From 171e96c1dd7c2f99c3e2f09fc9d060ecbee9bb07 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:51:12 -0700 Subject: [PATCH 05/15] Fix staged package resolution Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- scripts/stage-workbench-with-tarballs.mjs | 50 +++++++++-------------- 1 file changed, 20 insertions(+), 30 deletions(-) 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}`); From 4721f45c0c25ad45c4723842c13ff04fef14bc92 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:06:13 -0700 Subject: [PATCH 06/15] Simplify World configuration factories Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .changeset/lazy-world-factories.md | 6 ++ .changeset/typed-world-providers.md | 9 -- .../docs/v5/foundations/configuration.mdx | 83 +++++++------------ packages/config/README.md | 16 ++-- packages/config/src/index.ts | 6 -- packages/config/src/load.test.ts | 14 +++- packages/config/src/schema.ts | 8 +- .../core/src/runtime/world-config.test.ts | 3 +- packages/core/src/runtime/world.ts | 8 +- packages/docs-typecheck/src/type-checker.ts | 10 +++ packages/nest/src/workflow.module.test.ts | 5 +- packages/next/README.md | 12 +-- packages/next/src/index.test.ts | 7 +- packages/world-local/README.md | 16 ++-- packages/world-local/src/index.ts | 25 +----- packages/world-postgres/HOW_IT_WORKS.md | 8 +- packages/world-postgres/README.md | 18 ++-- packages/world-postgres/src/index.ts | 51 +----------- packages/world-vercel/README.md | 14 ++-- packages/world-vercel/src/index.ts | 32 +------ packages/world/README.md | 18 +--- packages/world/src/index.ts | 2 - packages/world/src/provider.ts | 15 ---- 23 files changed, 128 insertions(+), 258 deletions(-) create mode 100644 .changeset/lazy-world-factories.md delete mode 100644 .changeset/typed-world-providers.md delete mode 100644 packages/world/src/provider.ts diff --git a/.changeset/lazy-world-factories.md b/.changeset/lazy-world-factories.md new file mode 100644 index 0000000000..1a8a16d694 --- /dev/null +++ b/.changeset/lazy-world-factories.md @@ -0,0 +1,6 @@ +--- +"@workflow/config": minor +"@workflow/world": minor +--- + +Add lazy World factories and a shared Workflow configuration schema. diff --git a/.changeset/typed-world-providers.md b/.changeset/typed-world-providers.md deleted file mode 100644 index 8eff911609..0000000000 --- a/.changeset/typed-world-providers.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@workflow/config": minor -"@workflow/world": minor -"@workflow/world-local": minor -"@workflow/world-postgres": minor -"@workflow/world-vercel": minor ---- - -Add typed World provider helpers and a shared Workflow configuration schema. diff --git a/docs/content/docs/v5/foundations/configuration.mdx b/docs/content/docs/v5/foundations/configuration.mdx index ca5a476d3b..5386b57e92 100644 --- a/docs/content/docs/v5/foundations/configuration.mdx +++ b/docs/content/docs/v5/foundations/configuration.mdx @@ -14,13 +14,11 @@ related: Use `workflow.config.ts` to configure Workflow SDK: ```typescript title="workflow.config.ts" lineNumbers -import { defineConfig } from "workflow/config"; -import { postgresWorld } from "@workflow/world-postgres"; +import type { WorkflowConfig } from "workflow/config"; +import { createWorld } from "@workflow/world-postgres"; -export default defineConfig({ - world: postgresWorld({ - connectionString: () => process.env.WORKFLOW_POSTGRES_URL!, - }), +const config: WorkflowConfig = { + world: createWorld, build: { dirs: ["workflows"], sourcemap: false, @@ -29,7 +27,9 @@ export default defineConfig({ type: "next", lazyDiscovery: true, }, -}); +}; + +export default config; ``` @@ -40,9 +40,8 @@ export default defineConfig({ ## Config File -Default-export a static object. `defineConfig()` provides type checking. In a -monorepo, place the file in the application directory; the nearest config is -used. +Default-export a `WorkflowConfig` object. In a monorepo, place the file in the +application directory; the nearest config is used. ## Precedence @@ -53,55 +52,37 @@ From highest to lowest priority: 3. `workflow.config.ts` 4. Built-in defaults -## World Providers - -The `world` field accepts a typed `WorldProvider`: +## World -```typescript -import { localWorld } from "@workflow/world-local"; -import { postgresWorld } from "@workflow/world-postgres"; -import { vercelWorld } from "@workflow/world-vercel"; -``` - -World packages can expose their own typed helpers: - -{/* @skip-typecheck: conceptual custom provider package example */} - -```typescript -import { defineWorldProvider } from "@workflow/world"; - -export function hybridWorld(options: HybridOptions) { - return defineWorldProvider(() => createHybridWorld(options)); -} -``` +`world` is a zero-argument function that creates a World. It runs when the +application first needs the World, not when the config is loaded during a +build. Pass a World constructor directly, or wrap it to provide options. ## Different Worlds by Environment -Choose a World inside the provider factory based on the runtime environment: +Choose a World inside the factory based on the runtime environment: ```typescript title="workflow.config.ts" lineNumbers -import { defineConfig } from "workflow/config"; -import { defineWorldProvider } from "@workflow/world"; +import type { WorkflowConfig } from "workflow/config"; import { createLocalWorld } from "@workflow/world-local"; -import { createVercelWorld } from "@workflow/world-vercel"; - -const world = defineWorldProvider(() => { - switch (process.env.NODE_ENV) { - case "production": - return createVercelWorld(); - case "development": - case "test": - return createLocalWorld(); - default: - throw new Error(`Unexpected NODE_ENV: ${process.env.NODE_ENV}`); - } -}); - -export default defineConfig({ world }); -``` +import { createWorld as createPostgresWorld } from "@workflow/world-postgres"; + +const config: WorkflowConfig = { + world: () => { + switch (process.env.NODE_ENV) { + case "production": + return createPostgresWorld(); + case "development": + case "test": + return createLocalWorld(); + default: + throw new Error(`Unexpected NODE_ENV: ${process.env.NODE_ENV}`); + } + }, +}; -The factory runs when the application first needs the World. Builds load the -provider definition without creating either backend. +export default config; +``` ## Integration Settings diff --git a/packages/config/README.md b/packages/config/README.md index db04bf1c72..776eaabe76 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -5,13 +5,11 @@ Typed, shared configuration for Workflow SDK. Import it through `workflow/config`: ```ts -import { defineConfig } from 'workflow/config'; -import { postgresWorld } from '@workflow/world-postgres'; +import type { WorkflowConfig } from 'workflow/config'; +import { createWorld } from '@workflow/world-postgres'; -export default defineConfig({ - world: postgresWorld({ - connectionString: () => process.env.WORKFLOW_POSTGRES_URL!, - }), +const config: WorkflowConfig = { + world: createWorld, build: { dirs: ['workflows'], sourcemap: false, @@ -20,8 +18,10 @@ export default defineConfig({ type: 'next', lazyDiscovery: true, }, -}); +}; + +export default config; ``` -See the [configuration guide](https://workflow-sdk.dev/docs/foundations/configuration) +See the [configuration guide](https://workflow-sdk.dev/v5/docs/foundations/configuration) for the available settings. diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 4639ce1171..f7531c3094 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,9 +1,3 @@ export type { SourcemapMode, WorkflowConfig } from './schema.js'; export type WorkflowConfigLoader = typeof import('./load.js').loadWorkflowConfig; - -import type { WorkflowConfig } from './schema.js'; - -export function defineConfig(config: WorkflowConfig): WorkflowConfig { - return config; -} diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index edded50338..3d7821d091 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -49,6 +49,18 @@ describe('loadWorkflowConfig', () => { }); }); + it('loads a World factory without calling it', async () => { + const project = createProject({ + 'workflow.config.ts': `export default { + world: () => { throw new Error('must stay lazy'); } + };`, + }); + + const loaded = await loadWorkflowConfig({ cwd: project }); + + expect(loaded.config.world).toBeTypeOf('function'); + }); + it('rejects multiple config files in one directory', async () => { const project = createProject({ 'workflow.config.ts': `export default { build: { dirs: ['typescript'] } };`, @@ -86,7 +98,7 @@ describe('loadWorkflowConfig', () => { ).rejects.toThrow('configures "nest" but was loaded by "next"'); }); - it('rejects config functions and unknown keys', async () => { + it('rejects top-level config functions and unknown keys', async () => { const project = createProject({ 'workflow.config.ts': `export default () => ({ build: { dirs: ['workflows'] } });`, }); diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index e19b0a03ed..421783693c 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -1,7 +1,11 @@ -import { WorldProviderSchema } from '@workflow/world/provider.js'; +import type { World } from '@workflow/world'; import { QueueNamespaceSchema } from '@workflow/world/queue.js'; import { z } from 'zod/v4'; +const worldSchema = z.custom<() => World | Promise>( + (value) => typeof value === 'function' +); + const sourcemapSchema = z.union([ z.boolean(), z.enum(['inline', 'linked', 'external', 'both']), @@ -34,7 +38,7 @@ const integrationSchema = z.discriminatedUnion('type', [ ]); export const WorkflowConfigSchema = z.strictObject({ - world: WorldProviderSchema.optional(), + world: worldSchema.optional(), build: z .strictObject({ dirs: z.array(z.string().min(1)).min(1).optional(), diff --git a/packages/core/src/runtime/world-config.test.ts b/packages/core/src/runtime/world-config.test.ts index ee3dc10f34..bdc12ae925 100644 --- a/packages/core/src/runtime/world-config.test.ts +++ b/packages/core/src/runtime/world-config.test.ts @@ -1,6 +1,5 @@ import { setRuntimeWorkflowConfig } from '@workflow/config/runtime'; import type { World } from '@workflow/world'; -import { defineWorldProvider } 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'; @@ -32,7 +31,7 @@ describe('configured World', () => { const create = vi.fn(() => world); setRuntimeWorkflowConfig({ - world: defineWorldProvider(create), + world: create, queue: { namespace: 'app' }, }); diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index 7ca2fc047a..f350f029b8 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -146,7 +146,7 @@ async function resolveWorld(): Promise { if (config.world) { return { type: 'configured', - world: await config.world.create(), + world: await config.world(), }; } @@ -170,9 +170,9 @@ export const createWorld = async (): Promise => { export type WorldHandlers = Pick; /** - * Queue handlers and regular runtime calls share one managed World. Provider - * factories are never called by config loading or the build integrations; - * this path is reached only when host runtime code asks for a handler. + * 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 => { const world = await getWorld(); 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/src/workflow.module.test.ts b/packages/nest/src/workflow.module.test.ts index 7e47e90a50..5136cad10c 100644 --- a/packages/nest/src/workflow.module.test.ts +++ b/packages/nest/src/workflow.module.test.ts @@ -22,10 +22,7 @@ describe('WorkflowModule', () => { writeFileSync( join(project, 'workflow.config.ts'), `export default { - world: { - type: 'world-provider', - create: () => { throw new Error('must stay lazy'); } - }, + world: () => { throw new Error('must stay lazy'); }, build: { dirs: ['src/jobs'], sourcemap: false }, integration: { type: 'nest', outDir: '.generated/workflow' } };` diff --git a/packages/next/README.md b/packages/next/README.md index 9a9829639d..b7d3df5b2b 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -6,17 +6,19 @@ Shared build, World, queue, and Next-specific settings can live in `workflow.config.ts`: ```ts -import { defineConfig } from 'workflow/config'; -import { localWorld } from '@workflow/world-local'; +import type { WorkflowConfig } from 'workflow/config'; +import { createLocalWorld } from '@workflow/world-local'; -export default defineConfig({ - world: localWorld(), +const config: WorkflowConfig = { + world: createLocalWorld, build: { sourcemap: false }, integration: { type: 'next', lazyDiscovery: true, }, -}); +}; + +export default config; ``` Wrap `next.config.ts` with `withWorkflow()` to activate directive transforms. diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index f773264406..e1d6a4c3e1 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -275,11 +275,8 @@ describe('withWorkflow builder config', () => { process.chdir(projectDir); writeFile( join(projectDir, 'workflow.config.ts'), - `const world = { - type: 'world-provider', - create: () => { - throw new Error('World provider factory must not run during builds'); - } + `const world = () => { + throw new Error('World factory must not run during builds'); }; export default { diff --git a/packages/world-local/README.md b/packages/world-local/README.md index 6327033165..0488385920 100644 --- a/packages/world-local/README.md +++ b/packages/world-local/README.md @@ -8,18 +8,18 @@ Used by default on `next dev` and `next start`. ## workflow.config.ts -Use `localWorld()` in `workflow.config.ts`: +Use `createLocalWorld()` in `workflow.config.ts`: ```ts -import { defineConfig } from 'workflow/config'; -import { localWorld } from '@workflow/world-local'; +import type { WorkflowConfig } from 'workflow/config'; +import { createLocalWorld } from '@workflow/world-local'; -export default defineConfig({ - world: localWorld({ +const config: WorkflowConfig = { + world: () => createLocalWorld({ dataDir: '.workflow-data', port: 3000, }), -}); -``` +}; -Environment variables take precedence over values in `workflow.config.ts`. +export default config; +``` diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts index aab6bb89ae..f028dccc3d 100644 --- a/packages/world-local/src/index.ts +++ b/packages/world-local/src/index.ts @@ -1,12 +1,8 @@ import { promises as fs } from 'node:fs'; import { rm } from 'node:fs/promises'; import path from 'node:path'; -import type { QueuePrefix, World, WorldProvider } from '@workflow/world'; -import { - defineWorldProvider, - reenqueueActiveRuns, - SPEC_VERSION_CURRENT, -} from '@workflow/world'; +import type { QueuePrefix, World } from '@workflow/world'; +import { reenqueueActiveRuns, SPEC_VERSION_CURRENT } from '@workflow/world'; import type { Config } from './config.js'; import { config } from './config.js'; import { @@ -24,7 +20,6 @@ import { hashToken, hookRecoveryMarkerPath } from './storage/helpers.js'; import { createStorage } from './storage.js'; import { createStreamer } from './streamer.js'; -export type { Config as LocalWorldConfig } from './config.js'; // Re-export init types and utilities for consumers export { DataDirAccessError, @@ -34,6 +29,7 @@ export { type ParsedVersion, parseVersion, } from './init.js'; + export type { DirectHandler } from './queue.js'; export type LocalWorld = World & { @@ -176,18 +172,3 @@ export function createLocalWorld(args?: Partial): LocalWorld { }, }; } - -/** Creates a local provider for workflow.config.ts. */ -export function localWorld(args: Partial = {}): WorldProvider { - return defineWorldProvider(() => - createLocalWorld({ - ...args, - dataDir: process.env.WORKFLOW_LOCAL_DATA_DIR ?? args.dataDir, - baseUrl: - process.env.WORKFLOW_LOCAL_BASE_URL ?? - (process.env.PORT - ? `http://localhost:${process.env.PORT}` - : args.baseUrl), - }) - ); -} diff --git a/packages/world-postgres/HOW_IT_WORKS.md b/packages/world-postgres/HOW_IT_WORKS.md index 8fbb8ac78e..b9d6eadb1d 100644 --- a/packages/world-postgres/HOW_IT_WORKS.md +++ b/packages/world-postgres/HOW_IT_WORKS.md @@ -34,8 +34,8 @@ Real-time data streaming via **PostgreSQL LISTEN/NOTIFY**: ## Setup Call `world.start()` to initialize graphile-worker workers when constructing a -World directly. A `postgresWorld()` provider configured in -`workflow.config.ts` is started once by `getWorld()`. +World directly. A World factory configured in `workflow.config.ts` is started +once by `getWorld()`. When `.start()` is called, workers begin listening to graphile-worker queues. When a job arrives, the worker executes the queue message over the workflow @@ -59,5 +59,5 @@ if (process.env.NEXT_RUNTIME !== "edge") { } ``` -When using `createWorld()` directly instead of `postgresWorld()`, call -`world.start()` yourself. +When using `createWorld()` outside `workflow.config.ts`, call `world.start()` +yourself. diff --git a/packages/world-postgres/README.md b/packages/world-postgres/README.md index d71f964230..ba3d5dd65c 100644 --- a/packages/world-postgres/README.md +++ b/packages/world-postgres/README.md @@ -63,23 +63,23 @@ const worldFromPool = createWorld({ pool }); ### workflow.config.ts -Use `postgresWorld()` in `workflow.config.ts`: +Use `createWorld()` in `workflow.config.ts`: ```typescript -import { defineConfig } from 'workflow/config'; -import { postgresWorld } from '@workflow/world-postgres'; +import type { WorkflowConfig } from 'workflow/config'; +import { createWorld } from '@workflow/world-postgres'; -export default defineConfig({ - world: postgresWorld({ - connectionString: () => process.env.WORKFLOW_POSTGRES_URL!, +const config: WorkflowConfig = { + world: () => createWorld({ + connectionString: process.env.WORKFLOW_POSTGRES_URL!, jobPrefix: 'myapp_', queueConcurrency: 50, maxPoolSize: 52, }), -}); -``` +}; -Environment variables take precedence over values passed to `postgresWorld()`. +export default config; +``` ## Configuration Options diff --git a/packages/world-postgres/src/index.ts b/packages/world-postgres/src/index.ts index 72c410ed8a..9ad7565e06 100644 --- a/packages/world-postgres/src/index.ts +++ b/packages/world-postgres/src/index.ts @@ -1,9 +1,5 @@ -import type { Storage, World, WorldProvider } from '@workflow/world'; -import { - defineWorldProvider, - reenqueueActiveRuns, - SPEC_VERSION_CURRENT, -} from '@workflow/world'; +import type { Storage, World } from '@workflow/world'; +import { reenqueueActiveRuns, SPEC_VERSION_CURRENT } from '@workflow/world'; import { Pool } from 'pg'; import type { PostgresWorldConfig } from './config.js'; import { createClient, type Drizzle } from './drizzle/index.js'; @@ -34,15 +30,6 @@ function getDefaultMaxPoolSize(): number | undefined { return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } -function getQueueConcurrencyFromEnv(): number | undefined { - const parsed = parseInt( - process.env.WORKFLOW_POSTGRES_WORKER_CONCURRENCY || '', - 10 - ); - - return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; -} - export function createWorld( config: PostgresWorldConfig = { connectionString: @@ -91,40 +78,6 @@ export function createWorld( }; } -type PostgresConnectionConfig = Extract< - PostgresWorldConfig, - { connectionString: string } ->; - -export type PostgresWorldProviderConfig = Pick< - PostgresConnectionConfig, - 'jobPrefix' | 'queueConcurrency' | 'maxPoolSize' | 'streamFlushIntervalMs' -> & { - connectionString?: string | (() => string); -}; - -/** Creates a PostgreSQL provider for workflow.config.ts. */ -export function postgresWorld( - config: PostgresWorldProviderConfig = {} -): WorldProvider { - return defineWorldProvider(() => { - const connectionString = - process.env.WORKFLOW_POSTGRES_URL ?? - (typeof config.connectionString === 'function' - ? config.connectionString() - : config.connectionString); - - return createWorld({ - connectionString: - connectionString ?? 'postgres://world:world@localhost:5432/world', - jobPrefix: process.env.WORKFLOW_POSTGRES_JOB_PREFIX ?? config.jobPrefix, - queueConcurrency: getQueueConcurrencyFromEnv() ?? config.queueConcurrency, - maxPoolSize: getDefaultMaxPoolSize() ?? config.maxPoolSize, - streamFlushIntervalMs: config.streamFlushIntervalMs, - }); - }); -} - // Re-export schema for users who want to extend or inspect the database schema export type { PostgresWorldConfig } from './config.js'; export * from './drizzle/schema.js'; diff --git a/packages/world-vercel/README.md b/packages/world-vercel/README.md index 8aa622f1f0..319ba0a5a5 100644 --- a/packages/world-vercel/README.md +++ b/packages/world-vercel/README.md @@ -8,15 +8,17 @@ Used by default for deployments on Vercel. Authentication and API endpoints are ## workflow.config.ts -Use `vercelWorld()` to select the Vercel backend explicitly: +Use `createVercelWorld()` to select the Vercel backend explicitly: ```ts -import { defineConfig } from 'workflow/config'; -import { vercelWorld } from '@workflow/world-vercel'; +import type { WorkflowConfig } from 'workflow/config'; +import { createVercelWorld } from '@workflow/world-vercel'; + +const config: WorkflowConfig = { + world: createVercelWorld, +}; -export default defineConfig({ - world: vercelWorld(), -}); +export default config; ``` ## Custom dispatcher diff --git a/packages/world-vercel/src/index.ts b/packages/world-vercel/src/index.ts index ad3f4bb61e..0bdaa91ed6 100644 --- a/packages/world-vercel/src/index.ts +++ b/packages/world-vercel/src/index.ts @@ -1,8 +1,5 @@ -import type { World, WorldProvider } from '@workflow/world'; -import { - defineWorldProvider, - SPEC_VERSION_SUPPORTS_COMPRESSION, -} from '@workflow/world'; +import type { World } from '@workflow/world'; +import { SPEC_VERSION_SUPPORTS_COMPRESSION } from '@workflow/world'; import { createGetEncryptionKeyForRun } from './encryption.js'; import { instrumentObject } from './instrumentObject.js'; import { createQueue } from './queue.js'; @@ -54,28 +51,3 @@ export function createVercelWorld(config?: APIConfig): World { resolveLatestDeploymentId: createResolveLatestDeploymentId(config), }; } - -export type VercelWorldProviderConfig = Omit & { - token?: string | (() => string | undefined); -}; - -/** Creates a Vercel provider for workflow.config.ts. */ -export function vercelWorld( - config: VercelWorldProviderConfig = {} -): WorldProvider { - return defineWorldProvider(() => { - const token = - process.env.VERCEL_TOKEN ?? - (typeof config.token === 'function' ? config.token() : config.token); - - return createVercelWorld({ - ...config, - token, - projectConfig: config.projectConfig && { - ...config.projectConfig, - projectId: - process.env.VERCEL_PROJECT_ID ?? config.projectConfig.projectId, - }, - }); - }); -} diff --git a/packages/world/README.md b/packages/world/README.md index 4ce1ae14f0..16a28fc393 100644 --- a/packages/world/README.md +++ b/packages/world/README.md @@ -4,19 +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. -It also defines the `WorldProvider` contract used by `workflow.config.ts`. - -Custom World packages can expose a typed helper with `defineWorldProvider()`: - - - -```ts -import { defineWorldProvider } from '@workflow/world'; - -export function hybridWorld(options: HybridOptions) { - return defineWorldProvider(() => createHybridWorld(options)); -} -``` - -Most applications should use a provider helper from a World implementation -instead of importing this package directly. +Application code usually imports a World implementation package. World packages +implement the `World` interface exported here. diff --git a/packages/world/src/index.ts b/packages/world/src/index.ts index 679069cb15..85ae00c274 100644 --- a/packages/world/src/index.ts +++ b/packages/world/src/index.ts @@ -24,8 +24,6 @@ export { export type * from './hooks.js'; export { HookSchema } from './hooks.js'; export type * from './interfaces.js'; -export type * from './provider.js'; -export { defineWorldProvider } from './provider.js'; export type * from './queue.js'; export { getQueuePrefixKind, diff --git a/packages/world/src/provider.ts b/packages/world/src/provider.ts deleted file mode 100644 index ae8a6c0961..0000000000 --- a/packages/world/src/provider.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from 'zod/v4'; -import type { World } from './interfaces.js'; - -type WorldFactory = () => World | Promise; - -export const WorldProviderSchema = z.strictObject({ - type: z.literal('world-provider'), - create: z.custom((value) => typeof value === 'function'), -}); - -export type WorldProvider = z.infer; - -export function defineWorldProvider(create: WorldFactory): WorldProvider { - return { type: 'world-provider', create }; -} From 39f3104460f0a685ba733a6266a9a9fbe34b0b6a Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:08:57 -0700 Subject: [PATCH 07/15] Document static workflow configuration Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- docs/content/docs/v5/foundations/configuration.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/v5/foundations/configuration.mdx b/docs/content/docs/v5/foundations/configuration.mdx index 5386b57e92..3b04654298 100644 --- a/docs/content/docs/v5/foundations/configuration.mdx +++ b/docs/content/docs/v5/foundations/configuration.mdx @@ -40,8 +40,9 @@ export default config; ## Config File -Default-export a `WorkflowConfig` object. In a monorepo, place the file in the -application directory; the nearest config is used. +Default-export a `WorkflowConfig` object. Top-level config functions are not +supported; keep runtime World selection inside `world`. In a monorepo, place +the file in the application directory; the nearest config is used. ## Precedence From aa4257fa77b9b3dc23aa8b98162f2a7a2a006b54 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:17:31 -0700 Subject: [PATCH 08/15] Simplify config integration plumbing Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/builders/src/constants.ts | 8 ++------ packages/nitro/src/builders.ts | 25 ++++++------------------- packages/nitro/src/index.ts | 6 +----- packages/nitro/src/vite.ts | 4 ++-- 4 files changed, 11 insertions(+), 32 deletions(-) diff --git a/packages/builders/src/constants.ts b/packages/builders/src/constants.ts index 44958ef91f..086d20da94 100644 --- a/packages/builders/src/constants.ts +++ b/packages/builders/src/constants.ts @@ -1,9 +1,5 @@ import { getQueueTopicPrefix } from '@workflow/world'; -function resolveQueueNamespace(namespace: string | undefined) { - return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE; -} - /** * Creates a queue trigger configuration for the workflow handler. * Handles both workflow orchestration and step execution on the same route. @@ -22,7 +18,7 @@ function resolveQueueNamespace(namespace: string | undefined) { * createWorkflowQueueTrigger('custom') */ export function createWorkflowQueueTrigger(namespace?: string) { - const resolvedNamespace = resolveQueueNamespace(namespace); + const resolvedNamespace = namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE; return { type: 'queue/v2beta' as const, @@ -39,7 +35,7 @@ export function createWorkflowQueueTrigger(namespace?: string) { * not need `WORKFLOW_QUEUE_NAMESPACE` at runtime. */ export function createWorkflowEntrypointOptionsCode(namespace?: string) { - const resolvedNamespace = resolveQueueNamespace(namespace); + const resolvedNamespace = namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE; if (!resolvedNamespace) { return ''; diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index a5c2a3f6aa..74e985dcae 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -8,35 +8,22 @@ import type { LoadedWorkflowConfig } from '@workflow/config/load'; import type { Nitro } from 'nitro/types'; 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[] { - const external = (nitro.options as NitroV2ExternalsOptions).externals - ?.external; - return ( - external?.filter((entry): entry is string => typeof entry === 'string') ?? - [] - ); -} 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 ?? []), - ...getNitroStringExternals(nitro), + ...nitroExternals.filter( + (entry): entry is string => typeof entry === 'string' + ), ]), ]; diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index 8afdeb7ede..d80f6004f8 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -27,7 +27,7 @@ function isNitroV2(nitro: Nitro): boolean { return !nitro.routing; } -const nitroModule = { +export const nitroModule = { name: 'workflow/nitro', async setup(nitro: Nitro): Promise { const loadedWorkflowConfig = await loadWorkflowConfig({ @@ -331,10 +331,6 @@ const nitroModule = { }, }; -export function setupNitro(nitro: Nitro): Promise { - return nitroModule.setup(nitro); -} - export default { name: nitroModule.name, async setup(nitro: Nitro) { diff --git a/packages/nitro/src/vite.ts b/packages/nitro/src/vite.ts index a1f4946e23..d4b954434f 100644 --- a/packages/nitro/src/vite.ts +++ b/packages/nitro/src/vite.ts @@ -7,7 +7,7 @@ import { join } from 'pathe'; import type { Plugin } from 'vite'; import type { LocalBuilder } from './builders.js'; import type { ModuleOptions } from './index.js'; -import { setupNitro } from './index.js'; +import { nitroModule } from './index.js'; export function workflow(options?: ModuleOptions): Plugin[] { let builder: LocalBuilder | undefined; @@ -41,7 +41,7 @@ export function workflow(options?: ModuleOptions): Plugin[] { ...options, _vite: true, }; - builder = await setupNitro(nitro); + builder = await nitroModule.setup(nitro); }, }, // NOTE: This is a workaround because Nitro passes the 404 requests to the dev server to handle. From cca2df2d6b04f459abbb4d48af0afef4e67c45df Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:21:44 -0700 Subject: [PATCH 09/15] Refine workflow configuration docs Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .../workflow-next/with-workflow.mdx | 22 +++++--------- .../v5/api-reference/workflow-nitro/index.mdx | 13 +++++---- .../docs/v5/foundations/configuration.mdx | 29 +++++++------------ docs/content/docs/v5/foundations/meta.json | 2 +- packages/world-postgres/HOW_IT_WORKS.md | 3 +- 5 files changed, 27 insertions(+), 42 deletions(-) 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 5bfbcfa51e..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,11 +77,9 @@ Use the smallest directory that contains every workspace package imported by you ## Options -Build and Next.js integration settings can be placed in -[`workflow.config.ts`](/docs/foundations/configuration). - -`withWorkflow` also accepts an optional second argument. Values passed there -take precedence over environment variables and `workflow.config.ts`. +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"; @@ -106,7 +99,7 @@ export default withWorkflow(nextConfig, { | Option | Type | Default | Description | | --- | --- | --- | --- | -| `workflows.local.port` | `number` | — | Sets the local queue URL to `http://localhost:`, overriding local URL environment variables. 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 @@ -138,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 590d9a9478..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,11 +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. - -Generic build settings and Nitro-specific options may also be placed in -[`workflow.config.ts`](/docs/foundations/configuration). Explicit -`nitro.options.workflow` values take precedence. +Add the [`workflow/nitro` Nitro module](https://v3.nitro.build/guide/modules) to +transform workflow directives, build bundles, and register runtime routes. ## Usage @@ -24,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. @@ -34,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 index 3b04654298..e18138e9e1 100644 --- a/docs/content/docs/v5/foundations/configuration.mdx +++ b/docs/content/docs/v5/foundations/configuration.mdx @@ -1,8 +1,8 @@ --- title: Configuration -description: Configure Workflow SDK worlds, builds, queues, and host integrations in one typed file. +description: Configure Workflow SDK with a typed workflow.config.ts file. type: conceptual -summary: Use workflow.config.ts as the typed source of truth for Workflow SDK settings. +summary: Configure the World, builds, and framework integration for your app. prerequisites: - /docs/foundations/workflows-and-steps related: @@ -11,7 +11,7 @@ related: - /docs/api-reference/workflow-next/with-workflow --- -Use `workflow.config.ts` to configure Workflow SDK: +Create `workflow.config.ts` in your application: ```typescript title="workflow.config.ts" lineNumbers import type { WorkflowConfig } from "workflow/config"; @@ -33,16 +33,11 @@ export default config; ``` - The config file supplies settings; it does not activate a framework - integration. For example, Next.js projects must still wrap - `next.config.ts` with `withWorkflow()`. + Next.js projects also wrap `next.config.ts` with `withWorkflow()`. -## Config File - -Default-export a `WorkflowConfig` object. Top-level config functions are not -supported; keep runtime World selection inside `world`. In a monorepo, place -the file in the application directory; the nearest config is used. +In a monorepo, place a config file in each application that needs its own +settings. ## Precedence @@ -55,11 +50,10 @@ From highest to lowest priority: ## World -`world` is a zero-argument function that creates a World. It runs when the -application first needs the World, not when the config is loaded during a -build. Pass a World constructor directly, or wrap it to provide options. +`world` is the function used to create the application's World. Assign a +`createWorld` function directly, or wrap it when passing options. -## Different Worlds by Environment +## Worlds by Environment Choose a World inside the factory based on the runtime environment: @@ -87,9 +81,8 @@ export default config; ## Integration Settings -`integration` is optional and only needed for integration-specific behavior. -It is a discriminated union, so one config cannot contain settings for -multiple integrations. +Use `integration` for framework-specific settings. Set `type` to the framework +you are configuring: {/* @skip-typecheck: mutually exclusive config fragments */} diff --git a/docs/content/docs/v5/foundations/meta.json b/docs/content/docs/v5/foundations/meta.json index 41b526c31b..d808f012b0 100644 --- a/docs/content/docs/v5/foundations/meta.json +++ b/docs/content/docs/v5/foundations/meta.json @@ -2,7 +2,6 @@ "title": "Foundations", "pages": [ "workflows-and-steps", - "configuration", "starting-workflows", "errors-and-retries", "hooks", @@ -10,6 +9,7 @@ "cancellation", "serialization", "idempotency", + "configuration", "versioning" ], "defaultOpen": true diff --git a/packages/world-postgres/HOW_IT_WORKS.md b/packages/world-postgres/HOW_IT_WORKS.md index b9d6eadb1d..554c5f97ed 100644 --- a/packages/world-postgres/HOW_IT_WORKS.md +++ b/packages/world-postgres/HOW_IT_WORKS.md @@ -34,8 +34,7 @@ Real-time data streaming via **PostgreSQL LISTEN/NOTIFY**: ## Setup Call `world.start()` to initialize graphile-worker workers when constructing a -World directly. A World factory configured in `workflow.config.ts` is started -once by `getWorld()`. +World directly. The runtime starts a World selected in `workflow.config.ts`. When `.start()` is called, workers begin listening to graphile-worker queues. When a job arrives, the worker executes the queue message over the workflow From ed4a0b7447d379a86bc7f92844a5c1478d6e4647 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:44:45 -0700 Subject: [PATCH 10/15] Refine module-based workflow configuration Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .changeset/lazy-world-factories.md | 3 +- .changeset/shared-config-runtime.md | 2 +- .../docs/v5/foundations/configuration.mdx | 63 +++--- packages/builders/src/base-builder.ts | 14 +- packages/builders/src/constants.test.ts | 4 +- packages/builders/src/constants.ts | 40 +++- .../builders/src/resolve-sourcemap.test.ts | 11 +- .../builders/src/vercel-build-output-api.ts | 4 +- packages/config/README.md | 21 +- packages/config/src/index.ts | 1 + packages/config/src/load.test.ts | 81 +++++++- packages/config/src/load.ts | 74 ++++++- packages/config/src/runtime-binding.ts | 7 +- packages/config/src/runtime.ts | 14 +- packages/config/src/schema.ts | 8 +- packages/nest/src/workflow.module.test.ts | 13 +- packages/nest/src/workflow.module.ts | 11 +- packages/next/README.md | 23 --- packages/next/src/builder-eager.ts | 2 +- packages/next/src/index.test.ts | 91 +++------ packages/next/src/index.ts | 10 +- packages/nitro/src/index.test.ts | 192 +++++++++--------- packages/nitro/src/index.ts | 9 +- packages/world-local/README.md | 17 -- packages/world-postgres/HOW_IT_WORKS.md | 19 +- packages/world-postgres/README.md | 22 +- packages/world-vercel/README.md | 16 +- packages/world/README.md | 4 +- packages/world/src/interfaces.ts | 2 + 29 files changed, 418 insertions(+), 360 deletions(-) diff --git a/.changeset/lazy-world-factories.md b/.changeset/lazy-world-factories.md index 1a8a16d694..aad0065a71 100644 --- a/.changeset/lazy-world-factories.md +++ b/.changeset/lazy-world-factories.md @@ -1,6 +1,7 @@ --- "@workflow/config": minor "@workflow/world": minor +"@workflow/world-postgres": patch --- -Add lazy World factories and a shared Workflow configuration schema. +Add typed Workflow configuration with module-based lazy World providers. diff --git a/.changeset/shared-config-runtime.md b/.changeset/shared-config-runtime.md index b7e28737a9..d9ea52ef75 100644 --- a/.changeset/shared-config-runtime.md +++ b/.changeset/shared-config-runtime.md @@ -5,4 +5,4 @@ "workflow": minor --- -Load shared Workflow configuration across runtime, build, and CLI entry points. +Bundle configured World modules and queue settings across runtime, build, and CLI entry points. diff --git a/docs/content/docs/v5/foundations/configuration.mdx b/docs/content/docs/v5/foundations/configuration.mdx index e18138e9e1..ea17d1d5c7 100644 --- a/docs/content/docs/v5/foundations/configuration.mdx +++ b/docs/content/docs/v5/foundations/configuration.mdx @@ -15,23 +15,39 @@ Create `workflow.config.ts` in your application: ```typescript title="workflow.config.ts" lineNumbers import type { WorkflowConfig } from "workflow/config"; -import { createWorld } from "@workflow/world-postgres"; -const config: WorkflowConfig = { - world: createWorld, +const config = { + world: "./workflow.world.ts", build: { dirs: ["workflows"], sourcemap: false, }, integration: { type: "next", - lazyDiscovery: true, + 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()`. @@ -50,33 +66,34 @@ From highest to lowest priority: ## World -`world` is the function used to create the application's World. Assign a -`createWorld` function directly, or wrap it when passing options. +`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.config.ts" lineNumbers -import type { WorkflowConfig } from "workflow/config"; +```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 config: WorkflowConfig = { - world: () => { - switch (process.env.NODE_ENV) { - case "production": - return createPostgresWorld(); - case "development": - case "test": - return createLocalWorld(); - default: - throw new Error(`Unexpected NODE_ENV: ${process.env.NODE_ENV}`); - } - }, +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 config; +export default world; ``` ## Integration Settings @@ -88,7 +105,7 @@ you are configuring: ```typescript // Next.js -integration: { type: "next", lazyDiscovery: true } +integration: { type: "next", local: { port: 4000 } } // Nitro integration: { type: "nitro", typescriptPlugin: true, runtime: "nodejs24.x" } diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 7327360153..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, @@ -236,7 +236,7 @@ export abstract class BaseBuilder { } private get runtimeConfigPlugins(): esbuild.Plugin[] { - const path = this.config.workflowConfig?.path; + const path = this.config.workflowConfig?.runtimePath; if (!path) return []; return [ { @@ -1449,7 +1449,7 @@ export const __steps_registered = true; } const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode( - this.queueNamespace + { namespace: this.queueNamespace } ); const bundleFinal = async (interimBundle: string) => { @@ -1640,9 +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( - this.queueNamespace - ); + const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode({ + namespace: this.queueNamespace, + }); const combinedFunctionCode = `// biome-ignore-all lint: generated file /* eslint-disable */ @@ -1714,7 +1714,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo const combinedBundleFinal = async (interimBundleText: string) => { const escaped = interimBundleText.replace(/[\\`$]/g, '\\$&'); const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode( - this.queueNamespace + { namespace: this.queueNamespace } ); const code = `// biome-ignore-all lint: generated file /* eslint-disable */ diff --git a/packages/builders/src/constants.test.ts b/packages/builders/src/constants.test.ts index 3c73054d3e..2474a7e1a5 100644 --- a/packages/builders/src/constants.test.ts +++ b/packages/builders/src/constants.test.ts @@ -14,7 +14,7 @@ describe('createWorkflowQueueTrigger', () => { }); it('uses an explicit namespace when provided', () => { - expect(createWorkflowQueueTrigger('custom').topic).toBe( + expect(createWorkflowQueueTrigger({ namespace: 'custom' }).topic).toBe( '__custom_wkf_workflow_*' ); }); @@ -36,7 +36,7 @@ describe('createWorkflowEntrypointOptionsCode', () => { }); it('inlines an explicit namespace', () => { - expect(createWorkflowEntrypointOptionsCode('custom')).toBe( + expect(createWorkflowEntrypointOptionsCode({ namespace: 'custom' })).toBe( ', { namespace: "custom" }' ); }); diff --git a/packages/builders/src/constants.ts b/packages/builders/src/constants.ts index 086d20da94..0f88e13e50 100644 --- a/packages/builders/src/constants.ts +++ b/packages/builders/src/constants.ts @@ -1,4 +1,22 @@ -import { getQueueTopicPrefix } from '@workflow/world'; +const QUEUE_NAMESPACE_PATTERN = /^[a-z][a-z0-9]*$/; + +function resolveQueueNamespace(namespace?: string): string | undefined { + return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE ?? undefined; +} + +function getQueueTopicPrefix(kind: 'workflow' | 'step', namespace?: string) { + if (namespace !== undefined) { + if (!QUEUE_NAMESPACE_PATTERN.test(namespace)) { + throw new Error( + `Invalid queue namespace "${namespace}": must be lowercase alphanumeric, starting with a letter` + ); + } + + return `__${namespace}_wkf_${kind}_`; + } + + return `__wkf_${kind}_`; +} /** * Creates a queue trigger configuration for the workflow handler. @@ -15,14 +33,14 @@ import { getQueueTopicPrefix } from '@workflow/world'; * * @example * // namespaced: topic = '__custom_wkf_workflow_*' - * createWorkflowQueueTrigger('custom') + * createWorkflowQueueTrigger({ namespace: 'custom' }) */ -export function createWorkflowQueueTrigger(namespace?: string) { - const resolvedNamespace = namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE; +export function createWorkflowQueueTrigger(options?: { namespace?: string }) { + const namespace = resolveQueueNamespace(options?.namespace); return { type: 'queue/v2beta' as const, - topic: `${getQueueTopicPrefix('workflow', resolvedNamespace)}*`, + topic: `${getQueueTopicPrefix('workflow', namespace)}*`, consumer: 'default', retryAfterSeconds: 5, // Delay between retries (default: 60) initialDelaySeconds: 0, // Initial delay before first delivery (default: 0) @@ -34,17 +52,19 @@ export function createWorkflowQueueTrigger(namespace?: string) { * calls. The namespace is resolved while building so generated route files do * not need `WORKFLOW_QUEUE_NAMESPACE` at runtime. */ -export function createWorkflowEntrypointOptionsCode(namespace?: string) { - const resolvedNamespace = namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE; +export function createWorkflowEntrypointOptionsCode(options?: { + namespace?: string; +}) { + const namespace = resolveQueueNamespace(options?.namespace); - if (!resolvedNamespace) { + if (!namespace) { return ''; } // Reuse prefix construction for namespace validation. - getQueueTopicPrefix('workflow', resolvedNamespace); + getQueueTopicPrefix('workflow', namespace); - return `, { namespace: ${JSON.stringify(resolvedNamespace)} }`; + return `, { namespace: ${JSON.stringify(namespace)} }`; } /** diff --git a/packages/builders/src/resolve-sourcemap.test.ts b/packages/builders/src/resolve-sourcemap.test.ts index d9ceab7efc..fabee8e785 100644 --- a/packages/builders/src/resolve-sourcemap.test.ts +++ b/packages/builders/src/resolve-sourcemap.test.ts @@ -46,6 +46,7 @@ function createBuilder( ? undefined : { path: '/tmp/workflow.config.ts', + runtimePath: '/tmp/runtime-config.mjs', config: { build: { sourcemap: options.workflowSourcemap } }, }, }; @@ -85,9 +86,7 @@ describe('resolveSourcemap', () => { it('prefers explicit config over environment variable', () => { process.env.WORKFLOW_SOURCEMAP = 'inline'; - expect( - createBuilder(false, { watch: true }).callResolveSourcemap('inline') - ).toBe(false); + expect(createBuilder(false).callResolveSourcemap('inline')).toBe(false); expect(createBuilder('external').callResolveSourcemap('inline')).toBe( 'external' ); @@ -96,9 +95,9 @@ describe('resolveSourcemap', () => { it('prefers environment variable over workflow.config.ts', () => { process.env.WORKFLOW_SOURCEMAP = 'inline'; expect( - createBuilder(undefined, { workflowSourcemap: false }).callResolveSourcemap( - true - ) + createBuilder(undefined, { + workflowSourcemap: false, + }).callResolveSourcemap(true) ).toBe('inline'); }); diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts index da771620e3..db6ff9644b 100644 --- a/packages/builders/src/vercel-build-output-api.ts +++ b/packages/builders/src/vercel-build-output-api.ts @@ -36,7 +36,9 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder { // serves no purpose without maps. shouldAddSourcemapSupport: this.sourcemapsEnabled, maxDuration: 'max', - experimentalTriggers: [createWorkflowQueueTrigger(this.queueNamespace)], + experimentalTriggers: [ + createWorkflowQueueTrigger({ namespace: this.queueNamespace }), + ], runtime: this.config.runtime, }); diff --git a/packages/config/README.md b/packages/config/README.md index 776eaabe76..a40c97714f 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -2,26 +2,7 @@ Typed, shared configuration for Workflow SDK. -Import it through `workflow/config`: - -```ts -import type { WorkflowConfig } from 'workflow/config'; -import { createWorld } from '@workflow/world-postgres'; - -const config: WorkflowConfig = { - world: createWorld, - build: { - dirs: ['workflows'], - sourcemap: false, - }, - integration: { - type: 'next', - lazyDiscovery: true, - }, -}; - -export default config; -``` +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/src/index.ts b/packages/config/src/index.ts index f7531c3094..1d2a8cf617 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,3 +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.test.ts b/packages/config/src/load.test.ts index 3d7821d091..a6c7f5d06e 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -1,8 +1,16 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +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[] = []; @@ -21,6 +29,8 @@ function createProject(files: Record): string { } afterEach(() => { + delete (globalThis as { __workflowWorldImports?: number }) + .__workflowWorldImports; for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } @@ -32,7 +42,7 @@ describe('loadWorkflowConfig', () => { 'workflow.config.ts': `export default { build: { dirs: ['parent'] } };`, 'apps/web/workflow.config.ts': `export default { build: { dirs: ['app'], sourcemap: false }, - integration: { type: 'next', lazyDiscovery: false } + integration: { type: 'next', local: { port: 4321 } } };`, }); const app = join(project, 'apps', 'web'); @@ -45,20 +55,79 @@ describe('loadWorkflowConfig', () => { expect(loaded.path).toBe(join(app, 'workflow.config.ts')); expect(loaded.config).toEqual({ build: { dirs: ['app'], sourcemap: false }, - integration: { type: 'next', lazyDiscovery: false }, + integration: { type: 'next', local: { port: 4321 } }, }); }); - it('loads a World factory without calling it', async () => { + 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: () => { throw new Error('must stay lazy'); } + 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).toBeTypeOf('function'); + 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 () => { diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 68359356c8..8bd2a27a7b 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -1,8 +1,24 @@ import assert from 'node:assert/strict'; -import { existsSync, readdirSync, statSync } from 'node:fs'; -import { basename, extname, isAbsolute, join, resolve } from 'node:path'; +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, @@ -21,10 +37,11 @@ export type LoadWorkflowConfigOptions = { integration?: WorkflowIntegrationType; }; -export type LoadedWorkflowConfig = { - path: string | undefined; - config: WorkflowConfig; -}; +export type LoadedWorkflowConfig = + | { path: undefined; runtimePath: undefined; config: WorkflowConfig } + | { path: string; runtimePath: string; config: WorkflowConfig }; + +type FoundWorkflowConfig = Extract; async function discoverWorkflowConfig({ cwd, @@ -73,7 +90,7 @@ export async function loadWorkflowConfig( ): Promise { const path = await discoverWorkflowConfig(options); if (!path) { - return { path, config: {} }; + return { path: undefined, runtimePath: undefined, config: {} }; } const configModule = await createJiti(import.meta.url, { @@ -96,5 +113,46 @@ export async function loadWorkflowConfig( `${basename(path)} configures "${config.integration?.type}" but was loaded by "${options.integration}".` ); - return { path, config }; + 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 index 728f8b8b1a..43c40449f3 100644 --- a/packages/config/src/runtime-binding.ts +++ b/packages/config/src/runtime-binding.ts @@ -1,5 +1,10 @@ +import type { WorldProvider } from '@workflow/world'; import type { WorkflowConfig } from './schema.js'; -const config: WorkflowConfig | undefined = undefined; +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 index 79bdc802f3..3a1feeecf0 100644 --- a/packages/config/src/runtime.ts +++ b/packages/config/src/runtime.ts @@ -1,17 +1,17 @@ -import type { WorkflowConfig } from './schema.js'; +import type { RuntimeWorkflowConfig } from './runtime-binding.js'; -const RuntimeWorkflowConfig = Symbol.for('@workflow/config/runtime'); +const RuntimeWorkflowConfigSymbol = Symbol.for('@workflow/config/runtime'); const globals = globalThis as typeof globalThis & { - [RuntimeWorkflowConfig]?: WorkflowConfig; + [RuntimeWorkflowConfigSymbol]?: RuntimeWorkflowConfig; }; -export function getRuntimeWorkflowConfig(): WorkflowConfig | undefined { - return globals[RuntimeWorkflowConfig]; +export function getRuntimeWorkflowConfig(): RuntimeWorkflowConfig | undefined { + return globals[RuntimeWorkflowConfigSymbol]; } export function setRuntimeWorkflowConfig( - config: WorkflowConfig | undefined + config: RuntimeWorkflowConfig | undefined ): void { - globals[RuntimeWorkflowConfig] = config; + globals[RuntimeWorkflowConfigSymbol] = config; } diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 421783693c..3538a89a0f 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -1,11 +1,6 @@ -import type { World } from '@workflow/world'; import { QueueNamespaceSchema } from '@workflow/world/queue.js'; import { z } from 'zod/v4'; -const worldSchema = z.custom<() => World | Promise>( - (value) => typeof value === 'function' -); - const sourcemapSchema = z.union([ z.boolean(), z.enum(['inline', 'linked', 'external', 'both']), @@ -15,7 +10,6 @@ export type SourcemapMode = z.infer; const integrationSchema = z.discriminatedUnion('type', [ z.strictObject({ type: z.literal('next'), - lazyDiscovery: z.boolean().optional(), local: z .strictObject({ port: z.number().int().positive().max(65_535), @@ -38,7 +32,7 @@ const integrationSchema = z.discriminatedUnion('type', [ ]); export const WorkflowConfigSchema = z.strictObject({ - world: worldSchema.optional(), + world: z.string().min(1).optional(), build: z .strictObject({ dirs: z.array(z.string().min(1)).min(1).optional(), diff --git a/packages/nest/src/workflow.module.test.ts b/packages/nest/src/workflow.module.test.ts index 5136cad10c..29e6d77078 100644 --- a/packages/nest/src/workflow.module.test.ts +++ b/packages/nest/src/workflow.module.test.ts @@ -19,10 +19,14 @@ 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: () => { throw new Error('must stay lazy'); }, + world: './workflow.world.ts', build: { dirs: ['src/jobs'], sourcemap: false }, integration: { type: 'nest', outDir: '.generated/workflow' } };` @@ -40,9 +44,10 @@ describe('WorkflowModule', () => { expect(build).toHaveBeenCalledOnce(); expect(builder?.outDir).toBe(resolve(project, '.generated/workflow')); - expect(getRuntimeWorkflowConfig()).toMatchObject({ - build: { dirs: ['src/jobs'], sourcemap: false }, - integration: { type: 'nest' }, + const runtimeConfig = getRuntimeWorkflowConfig(); + expect(runtimeConfig?.world).toBeTypeOf('function'); + await expect(runtimeConfig?.world?.()).resolves.toMatchObject({ + marker: 'world', }); await module.onModuleDestroy(); diff --git a/packages/nest/src/workflow.module.ts b/packages/nest/src/workflow.module.ts index a47a226de7..56b6183d0e 100644 --- a/packages/nest/src/workflow.module.ts +++ b/packages/nest/src/workflow.module.ts @@ -6,7 +6,10 @@ import { type OnModuleInit, } from '@nestjs/common'; import { createBuildQueue } from '@workflow/builders'; -import { loadWorkflowConfig } from '@workflow/config/load'; +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'; @@ -76,7 +79,11 @@ export class WorkflowModule implements OnModuleInit, OnModuleDestroy { workflowConfig, }); - setRuntimeWorkflowConfig(config); + setRuntimeWorkflowConfig( + workflowConfig.path + ? createRuntimeWorkflowConfig(workflowConfig) + : undefined + ); const publicManifest = process.env.WORKFLOW_PUBLIC_MANIFEST === undefined diff --git a/packages/next/README.md b/packages/next/README.md index b7d3df5b2b..1bc4db3736 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -1,26 +1,3 @@ # @workflow/next Next.js plugin for [Workflow SDK](https://workflow-sdk.dev). - -Shared build, World, queue, and Next-specific settings can live in -`workflow.config.ts`: - -```ts -import type { WorkflowConfig } from 'workflow/config'; -import { createLocalWorld } from '@workflow/world-local'; - -const config: WorkflowConfig = { - world: createLocalWorld, - build: { sourcemap: false }, - integration: { - type: 'next', - lazyDiscovery: true, - }, -}; - -export default config; -``` - -Wrap `next.config.ts` with `withWorkflow()` to activate directive transforms. -Values passed in its optional second argument take precedence over environment -variables and `workflow.config.ts`. diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index fdf02418d6..da8a16cbe3 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -438,7 +438,7 @@ export async function getNextBuilderEager() { workflows: { maxDuration: 'max', experimentalTriggers: [ - createWorkflowQueueTrigger(this.queueNamespace), + createWorkflowQueueTrigger({ namespace: this.queueNamespace }), ], }, }; diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index e1d6a4c3e1..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, relative, resolve } from 'node:path'; +import { dirname, join, relative } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { @@ -221,66 +221,19 @@ describe('withWorkflow builder config', () => { expect(webpackConfig?.externals).toEqual([{ react: 'commonjs react' }]); }); - it('applies explicit local options before loading Next config', async () => { - process.env.PORT = '3000'; - process.env.WORKFLOW_LOCAL_BASE_URL = 'http://localhost:9876'; - let observedBaseUrl: string | undefined; - - const config = withWorkflow( - async () => { - observedBaseUrl = process.env.WORKFLOW_LOCAL_BASE_URL; - return {}; - }, - { - workflows: { - local: { port: 4321 }, - }, - } - ); - await config('phase-production-build', { defaultConfig: {} }); - - expect(process.env.PORT).toBe('4321'); - expect(process.env.WORKFLOW_LOCAL_BASE_URL).toBe('http://localhost:4321'); - expect(observedBaseUrl).toBe('http://localhost:4321'); - }); - - it('prefers environment variables over workflow.config.ts', async () => { + 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.config.ts'), - `export default { - build: { projectRoot: '../configured-root' }, - integration: { - type: 'next', - local: { port: 4321 } - } + join(projectDir, 'workflow.world.ts'), + `export default () => { + throw new Error('World provider must not run during builds'); };` ); - process.env.PORT = '9876'; - - try { - const config = withWorkflow({ outputFileTracingRoot: '/explicit-root' }); - await config('phase-production-build', { defaultConfig: {} }); - - expect(process.env.PORT).toBe('9876'); - expect(builderConfigs[0]?.projectRoot).toBe('/explicit-root'); - } finally { - process.chdir(originalCwd); - rmSync(projectDir, { recursive: true, force: true }); - } - }); - 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.config.ts'), - `const world = () => { - throw new Error('World factory must not run during builds'); -}; - -export default { - world, + `export default { + world: './workflow.world.ts', build: { dirs: ['jobs'], projectRoot: '../repo-root', @@ -295,21 +248,40 @@ export default { } };` ); + 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({ turbopack: { root: turbopackRoot } }); + 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('4321'); + 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: resolve(projectDir, '../repo-root'), + 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: { @@ -329,7 +301,10 @@ export default { '@workflow/config/runtime-binding' ] ).toBe( - `./${relative(turbopackRoot, join(projectDir, 'workflow.config.ts'))}` + `./${relative( + turbopackRoot, + join(projectDir, 'node_modules/.cache/workflow/runtime-config.mjs') + )}` ); } finally { process.chdir(originalCwd); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index ec9fedd1b1..96bc09168c 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -352,9 +352,15 @@ export function withWorkflow( } const loaderPath = require.resolve('./loader'); - const loadedWorkflowConfig = await loadWorkflowConfigForNext(); + 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.path; + const runtimeConfigPath = loadedWorkflowConfig.runtimePath; const nextIntegration = workflowConfig.integration?.type === 'next' ? workflowConfig.integration diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts index 70371cef02..c7f8211e6f 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -2,10 +2,33 @@ 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; @@ -124,40 +147,31 @@ describe('@workflow/nitro virtual handlers', () => { }); it('installs runtime config before importing unbundled dev routes', async () => { - const project = mkdtempSync(join(tmpdir(), 'workflow-nitro-config-')); - writeFileSync(join(project, 'workflow.config.ts'), 'export default {};'); - - try { - const nitro = createNitroStub({ - routing: false, - dev: true, - rootDir: project, - }); + const project = createProject('export default {};'); + const nitro = createNitroStub({ + routing: false, + dev: true, + rootDir: project, + }); - await nitroModule.setup(nitro); + 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).not.toContain('@workflow/config/runtime";'); - expect(source).toContain(assignment); - expect(source.indexOf(assignment)).toBeLessThan( - source.indexOf('import(currentImportPath)') - ); - } finally { - rmSync(project, { recursive: true, force: true }); - } + 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 = mkdtempSync(join(tmpdir(), 'workflow-nitro-config-')); - writeFileSync( - join(project, 'workflow.config.ts'), + const project = createProject( `export default { build: { dirs: ['server/jobs'], @@ -172,90 +186,66 @@ describe('@workflow/nitro workflow.config.ts', () => { } };` ); + const nitro = createNitroStub({ + routing: true, + preset: 'vercel', + rootDir: project, + }); - try { - const nitro = createNitroStub({ - routing: true, - preset: 'vercel', - rootDir: project, - }); - - await nitroModule.setup(nitro); + 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); - } finally { - rmSync(project, { recursive: true, force: true }); - } + 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 = mkdtempSync(join(tmpdir(), 'workflow-nitro-config-')); - writeFileSync( - join(project, 'workflow.config.ts'), + const project = createProject( `export default { build: { manifest: { public: true } }, queue: { namespace: 'configured' } };` ); - const queueNamespace = process.env.WORKFLOW_QUEUE_NAMESPACE; - const publicManifest = process.env.WORKFLOW_PUBLIC_MANIFEST; process.env.WORKFLOW_QUEUE_NAMESPACE = 'environment'; process.env.WORKFLOW_PUBLIC_MANIFEST = '0'; + const nitro = createNitroStub({ + routing: true, + preset: 'vercel', + rootDir: project, + }); - try { - const nitro = createNitroStub({ - routing: true, - preset: 'vercel', - rootDir: project, - }); - - await nitroModule.setup(nitro); + 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); - } finally { - if (queueNamespace === undefined) { - delete process.env.WORKFLOW_QUEUE_NAMESPACE; - } else { - process.env.WORKFLOW_QUEUE_NAMESPACE = queueNamespace; - } - if (publicManifest === undefined) { - delete process.env.WORKFLOW_PUBLIC_MANIFEST; - } else { - process.env.WORKFLOW_PUBLIC_MANIFEST = publicManifest; - } - rmSync(project, { recursive: true, force: true }); - } + 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); }); }); @@ -458,7 +448,11 @@ describe('@workflow/nitro isNitroV2 detection', () => { }); describe('@workflow/nitro externals forwarding', () => { - const loadedConfig = { path: undefined, config: {} } as const; + const loadedConfig = { + path: undefined, + runtimePath: undefined, + config: {}, + } as const; for (const [label, Builder] of [ ['VercelBuilder', VercelBuilder], diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index d80f6004f8..bd623ffe33 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -35,7 +35,7 @@ export const nitroModule = { integration: 'nitro', }); const workflowConfig = loadedWorkflowConfig.config; - const runtimeConfigPath = loadedWorkflowConfig.path; + const runtimeConfigPath = loadedWorkflowConfig.runtimePath; const nitroIntegration = workflowConfig.integration?.type === 'nitro' ? workflowConfig.integration @@ -52,9 +52,10 @@ export const nitroModule = { process.env.WORKFLOW_PUBLIC_MANIFEST === undefined ? (workflowConfig.build?.manifest?.public ?? false) : process.env.WORKFLOW_PUBLIC_MANIFEST === '1'; - const workflowQueueTrigger = createWorkflowQueueTrigger( - process.env.WORKFLOW_QUEUE_NAMESPACE ?? workflowConfig.queue?.namespace - ); + const workflowQueueTrigger = createWorkflowQueueTrigger({ + namespace: + process.env.WORKFLOW_QUEUE_NAMESPACE ?? workflowConfig.queue?.namespace, + }); const isVercelDeploy = !nitro.options.dev && nitro.options.preset === 'vercel'; diff --git a/packages/world-local/README.md b/packages/world-local/README.md index 0488385920..9e3f0d95cc 100644 --- a/packages/world-local/README.md +++ b/packages/world-local/README.md @@ -6,20 +6,3 @@ Stores workflow data as JSON files on disk and provides in-memory queuing. Autom Used by default on `next dev` and `next start`. -## workflow.config.ts - -Use `createLocalWorld()` in `workflow.config.ts`: - -```ts -import type { WorkflowConfig } from 'workflow/config'; -import { createLocalWorld } from '@workflow/world-local'; - -const config: WorkflowConfig = { - world: () => createLocalWorld({ - dataDir: '.workflow-data', - port: 3000, - }), -}; - -export default config; -``` diff --git a/packages/world-postgres/HOW_IT_WORKS.md b/packages/world-postgres/HOW_IT_WORKS.md index 554c5f97ed..4eb60af1a0 100644 --- a/packages/world-postgres/HOW_IT_WORKS.md +++ b/packages/world-postgres/HOW_IT_WORKS.md @@ -19,7 +19,7 @@ graph LR PG -.-> S["${prefix}steps
(steps)"] ``` -Jobs include retry logic (3 attempts), idempotency keys, durable delayed rescheduling, and configurable worker concurrency (default: 50). +Jobs include retry logic (3 attempts), idempotency keys, durable delayed rescheduling, and configurable worker concurrency (default: 10). ## Streaming @@ -33,20 +33,18 @@ Real-time data streaming via **PostgreSQL LISTEN/NOTIFY**: ## Setup -Call `world.start()` to initialize graphile-worker workers when constructing a -World directly. The runtime starts a World selected in `workflow.config.ts`. - -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**, eagerly call `getWorld()` from `instrumentation.ts|js` to -ensure a configured provider starts before request handling: +In **Next.js**, eagerly call `getWorld()` from `instrumentation.ts|js` to start +the configured provider before request handling: ```ts // instrumentation.ts @@ -57,6 +55,3 @@ if (process.env.NEXT_RUNTIME !== "edge") { }); } ``` - -When using `createWorld()` outside `workflow.config.ts`, call `world.start()` -yourself. diff --git a/packages/world-postgres/README.md b/packages/world-postgres/README.md index ba3d5dd65c..d23ceefeae 100644 --- a/packages/world-postgres/README.md +++ b/packages/world-postgres/README.md @@ -33,7 +33,7 @@ export WORKFLOW_POSTGRES_URL="postgres://username:password@localhost:5432/databa # Optional: Job prefix for queue operations export WORKFLOW_POSTGRES_JOB_PREFIX="myapp" -# Optional: Worker concurrency (default: 50) +# Optional: Worker concurrency (default: 10) export WORKFLOW_POSTGRES_WORKER_CONCURRENCY="10" # Optional: Internal pg.Pool max size (default: 10) @@ -61,26 +61,6 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const worldFromPool = createWorld({ pool }); ``` -### workflow.config.ts - -Use `createWorld()` in `workflow.config.ts`: - -```typescript -import type { WorkflowConfig } from 'workflow/config'; -import { createWorld } from '@workflow/world-postgres'; - -const config: WorkflowConfig = { - world: () => createWorld({ - connectionString: process.env.WORKFLOW_POSTGRES_URL!, - jobPrefix: 'myapp_', - queueConcurrency: 50, - maxPoolSize: 52, - }), -}; - -export default config; -``` - ## Configuration Options | Option | Type | Default | Description | diff --git a/packages/world-vercel/README.md b/packages/world-vercel/README.md index 319ba0a5a5..21ce59cc53 100644 --- a/packages/world-vercel/README.md +++ b/packages/world-vercel/README.md @@ -6,21 +6,6 @@ Integrates with Vercel's infrastructure for storage, queuing, and authentication Used by default for deployments on Vercel. Authentication and API endpoints are configured automatically in Vercel deployments. -## workflow.config.ts - -Use `createVercelWorld()` to select the Vercel backend explicitly: - -```ts -import type { WorkflowConfig } from 'workflow/config'; -import { createVercelWorld } from '@workflow/world-vercel'; - -const config: WorkflowConfig = { - world: createVercelWorld, -}; - -export default config; -``` - ## Custom dispatcher HTTP requests (including the queue) default to a shared undici `RetryAgent` that handles connection pooling and retries. Pass a custom `dispatcher` to override it — e.g. to tune undici on newer Node runtimes: @@ -32,3 +17,4 @@ import { setWorld } from '@workflow/core/runtime'; setWorld(createVercelWorld({ dispatcher: new Agent({ connections: 16 }) })); ``` + diff --git a/packages/world/README.md b/packages/world/README.md index 16a28fc393..7e3898ac29 100644 --- a/packages/world/README.md +++ b/packages/world/README.md @@ -4,5 +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. -Application code usually imports a World implementation package. World packages -implement the `World` interface exported here. +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; From f129832545691b2db914e7be2c71e9133ad2d0bf Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:56:08 -0700 Subject: [PATCH 11/15] Fix configured World lifecycle and paths Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/config/src/load.test.ts | 14 +++++ packages/config/src/load.ts | 6 +- .../core/src/runtime/world-config.test.ts | 55 +++++++++++++++++++ packages/core/src/runtime/world.ts | 54 +++++++++++------- packages/next/src/index.test.ts | 2 +- 5 files changed, 108 insertions(+), 23 deletions(-) diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index a6c7f5d06e..714859cb90 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -130,6 +130,20 @@ export default () => ({}); ); }); + it('rejects absolute World paths', async () => { + const project = createProject({ + 'workflow.world.ts': `export default () => ({});`, + }); + writeFileSync( + join(project, 'workflow.config.ts'), + `export default { world: ${JSON.stringify(join(project, 'workflow.world.ts'))} };` + ); + + await expect(loadWorkflowConfig({ cwd: project })).rejects.toThrow( + 'World module must be a relative path or package specifier' + ); + }); + it('rejects multiple config files in one directory', async () => { const project = createProject({ 'workflow.config.ts': `export default { build: { dirs: ['typescript'] } };`, diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 8bd2a27a7b..55ef3eebd0 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -115,7 +115,11 @@ export async function loadWorkflowConfig( const runtimeDir = join(dirname(path), 'node_modules', '.cache', 'workflow'); let world = config.world; - if (world?.startsWith('.') || (world && isAbsolute(world))) { + assert( + !world || !isAbsolute(world), + `World module must be a relative path or package specifier: ${world}` + ); + if (world?.startsWith('.')) { const worldPath = resolve(dirname(path), world); assert( existsSync(worldPath) && statSync(worldPath).isFile(), diff --git a/packages/core/src/runtime/world-config.test.ts b/packages/core/src/runtime/world-config.test.ts index bdc12ae925..57b77d79af 100644 --- a/packages/core/src/runtime/world-config.test.ts +++ b/packages/core/src/runtime/world-config.test.ts @@ -49,4 +49,59 @@ describe('configured World', () => { await closeWorld(); expect(close).toHaveBeenCalledOnce(); }); + + it('closes a World whose startup fails before retrying', async () => { + delete process.env.WORKFLOW_TARGET_WORLD; + const firstClose = vi.fn(async () => {}); + const first = { + start: vi.fn().mockRejectedValue(new Error('startup failed')), + close: firstClose, + } as unknown as World; + const second = { + start: vi.fn(async () => {}), + close: vi.fn(async () => {}), + } as unknown as World; + const create = vi + .fn() + .mockReturnValueOnce(first) + .mockReturnValueOnce(second); + setRuntimeWorkflowConfig({ world: create }); + + await expect(getWorld()).rejects.toThrow('startup failed'); + expect(firstClose).toHaveBeenCalledOnce(); + await expect(getWorld()).resolves.toBe(second); + expect(create).toHaveBeenCalledTimes(2); + }); + + it('does not cache a World closed during startup', async () => { + delete process.env.WORKFLOW_TARGET_WORLD; + let finishStart!: () => void; + const starting = new Promise((resolve) => { + finishStart = resolve; + }); + const firstClose = vi.fn(async () => {}); + const first = { + start: vi.fn(() => starting), + close: firstClose, + } as unknown as World; + const second = { + start: vi.fn(async () => {}), + close: vi.fn(async () => {}), + } as unknown as World; + const create = vi + .fn() + .mockReturnValueOnce(first) + .mockReturnValueOnce(second); + setRuntimeWorkflowConfig({ world: create }); + + const pendingWorld = getWorld(); + const closingWorld = closeWorld(); + finishStart(); + + await expect(pendingWorld).resolves.toBe(first); + await closingWorld; + expect(firstClose).toHaveBeenCalledOnce(); + await expect(getWorld()).resolves.toBe(second); + expect(create).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index f350f029b8..7bbe04b954 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -186,29 +186,41 @@ export const getWorld = async (): Promise => { if (globalSymbols[WorldCache]) { return globalSymbols[WorldCache]; } - // 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] = resolveWorld() - .then(async (resolved) => { - switch (resolved.type) { - case 'configured': + + let pendingWorld = globalSymbols[WorldCachePromise]; + if (!pendingWorld) { + pendingWorld = resolveWorld().then(async (resolved) => { + switch (resolved.type) { + case 'configured': + try { 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; - }); + } catch (error) { + await resolved.world.close?.(); + throw error; + } + return resolved.world; + case 'legacy': + return resolved.world; + default: + resolved satisfies never; + throw new Error('Unknown World resolution type'); + } + }); + globalSymbols[WorldCachePromise] = pendingWorld; + } + + try { + const world = await pendingWorld; + if (globalSymbols[WorldCachePromise] === pendingWorld) { + globalSymbols[WorldCache] = world; + } + return world; + } catch (error) { + if (globalSymbols[WorldCachePromise] === pendingWorld) { + globalSymbols[WorldCachePromise] = undefined; + } + throw error; } - globalSymbols[WorldCache] = await globalSymbols[WorldCachePromise]; - return globalSymbols[WorldCache]; }; /** diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index bac1481976..5a5f997644 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -304,7 +304,7 @@ describe('withWorkflow builder config', () => { `./${relative( turbopackRoot, join(projectDir, 'node_modules/.cache/workflow/runtime-config.mjs') - )}` + ).replaceAll('\\', '/')}` ); } finally { process.chdir(originalCwd); From 039c43e178b9288af4c80b3979f6e77175e43c3b Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:04:25 -0700 Subject: [PATCH 12/15] Simplify shared config integration Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .changeset/framework-shared-config.md | 3 +- .../docs/v5/foundations/configuration.mdx | 8 --- packages/builders/src/standalone.ts | 8 --- packages/config/src/load.test.ts | 15 ++--- packages/config/src/runtime.ts | 2 + packages/config/src/schema.ts | 13 ---- packages/core/src/runtime/get-world-lazy.ts | 8 ++- .../core/src/runtime/world-config.test.ts | 7 +- packages/core/src/runtime/world.ts | 9 ++- packages/nest/package.json | 2 - packages/nest/src/builder.ts | 43 +++--------- packages/nest/src/workflow.controller.ts | 9 +-- packages/nest/src/workflow.module.test.ts | 56 ---------------- packages/nest/src/workflow.module.ts | 65 +++++++------------ packages/next/src/index.test.ts | 6 +- packages/next/src/index.ts | 9 --- pnpm-lock.yaml | 6 -- 17 files changed, 62 insertions(+), 207 deletions(-) delete mode 100644 packages/nest/src/workflow.module.test.ts diff --git a/.changeset/framework-shared-config.md b/.changeset/framework-shared-config.md index ae5e46e94d..495a36ffae 100644 --- a/.changeset/framework-shared-config.md +++ b/.changeset/framework-shared-config.md @@ -1,7 +1,6 @@ --- -"@workflow/nest": minor "@workflow/next": minor "@workflow/nitro": minor --- -Add typed shared configuration support to the Next.js, Nitro, and Nest integrations. +Add typed shared configuration support to the Next.js and Nitro integrations. diff --git a/docs/content/docs/v5/foundations/configuration.mdx b/docs/content/docs/v5/foundations/configuration.mdx index ea17d1d5c7..8d60a8bc10 100644 --- a/docs/content/docs/v5/foundations/configuration.mdx +++ b/docs/content/docs/v5/foundations/configuration.mdx @@ -22,10 +22,6 @@ const config = { dirs: ["workflows"], sourcemap: false, }, - integration: { - type: "next", - local: { port: 4000 }, - }, } satisfies WorkflowConfig; export default config; @@ -104,9 +100,5 @@ 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/packages/builders/src/standalone.ts b/packages/builders/src/standalone.ts index b9dd9bb741..f52a0b1f75 100644 --- a/packages/builders/src/standalone.ts +++ b/packages/builders/src/standalone.ts @@ -1,14 +1,6 @@ import { BaseBuilder } from './base-builder.js'; -import type { WorkflowConfig } from './types.js'; export class StandaloneBuilder extends BaseBuilder { - constructor(config: WorkflowConfig) { - super({ - ...config, - dirs: ['.'], - }); - } - async build(): Promise { const inputFiles = await this.getInputFiles(); const tsconfigPath = await this.findTsConfigPath(); diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index 714859cb90..f28876e949 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -42,7 +42,7 @@ describe('loadWorkflowConfig', () => { '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 } } + integration: { type: 'next' } };`, }); const app = join(project, 'apps', 'web'); @@ -55,7 +55,7 @@ describe('loadWorkflowConfig', () => { expect(loaded.path).toBe(join(app, 'workflow.config.ts')); expect(loaded.config).toEqual({ build: { dirs: ['app'], sourcemap: false }, - integration: { type: 'next', local: { port: 4321 } }, + integration: { type: 'next' }, }); }); @@ -170,7 +170,7 @@ export default () => ({}); it('rejects integration config for another platform', async () => { const project = createProject({ - 'workflow.config.ts': `export default { integration: { type: 'nest' } };`, + 'workflow.config.ts': `export default { integration: { type: 'nitro' } };`, }); await expect( @@ -178,7 +178,7 @@ export default () => ({}); cwd: project, integration: 'next', }) - ).rejects.toThrow('configures "nest" but was loaded by "next"'); + ).rejects.toThrow('configures "nitro" but was loaded by "next"'); }); it('rejects top-level config functions and unknown keys', async () => { @@ -202,13 +202,8 @@ export default () => ({}); ); }); - it('rejects empty single-setting sections', () => { + it('rejects an empty queue section', () => { expect(() => WorkflowConfigSchema.parse({ queue: {} })).toThrow(); - expect(() => - WorkflowConfigSchema.parse({ - integration: { type: 'next', local: {} }, - }) - ).toThrow(); }); it('rejects mixed integration settings', () => { diff --git a/packages/config/src/runtime.ts b/packages/config/src/runtime.ts index 3a1feeecf0..fee86d30e7 100644 --- a/packages/config/src/runtime.ts +++ b/packages/config/src/runtime.ts @@ -1,3 +1,4 @@ +import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; import type { RuntimeWorkflowConfig } from './runtime-binding.js'; const RuntimeWorkflowConfigSymbol = Symbol.for('@workflow/config/runtime'); @@ -14,4 +15,5 @@ export function setRuntimeWorkflowConfig( config: RuntimeWorkflowConfig | undefined ): void { globals[RuntimeWorkflowConfigSymbol] = config; + setWorkflowQueueNamespace(config?.queue?.namespace); } diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 3538a89a0f..03b05a6b07 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -10,25 +10,12 @@ 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({ diff --git a/packages/core/src/runtime/get-world-lazy.ts b/packages/core/src/runtime/get-world-lazy.ts index bebb188c9d..44893fbb68 100644 --- a/packages/core/src/runtime/get-world-lazy.ts +++ b/packages/core/src/runtime/get-world-lazy.ts @@ -38,8 +38,12 @@ export async function getWorldLazy(): Promise { const g = globalThis as any; if (g[WorldCacheKey]) return g[WorldCacheKey]; if (g[WorldCachePromiseKey]) { - g[WorldCacheKey] = await g[WorldCachePromiseKey]; - return g[WorldCacheKey]; + const pendingWorld = g[WorldCachePromiseKey]; + const world = await pendingWorld; + if (g[WorldCachePromiseKey] === pendingWorld) { + g[WorldCacheKey] = world; + } + return world; } // If world.ts is statically present in this bundle, it has registered // getWorld on globalThis at module load. Prefer that over the dynamic diff --git a/packages/core/src/runtime/world-config.test.ts b/packages/core/src/runtime/world-config.test.ts index 57b77d79af..f50ec13245 100644 --- a/packages/core/src/runtime/world-config.test.ts +++ b/packages/core/src/runtime/world-config.test.ts @@ -1,7 +1,8 @@ import { setRuntimeWorkflowConfig } from '@workflow/config/runtime'; import type { World } from '@workflow/world'; -import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; +import { resolveQueueNamespace } from '@workflow/world/queue.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getWorldLazy } from './get-world-lazy.js'; import { closeWorld, getWorld, getWorldHandlers } from './world.js'; const targetWorld = process.env.WORKFLOW_TARGET_WORLD; @@ -9,7 +10,6 @@ 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 { @@ -35,6 +35,7 @@ describe('configured World', () => { queue: { namespace: 'app' }, }); + expect(resolveQueueNamespace()).toBe('app'); expect(create).not.toHaveBeenCalled(); const [resolved, handlers] = await Promise.all([ getWorld(), @@ -95,10 +96,12 @@ describe('configured World', () => { setRuntimeWorkflowConfig({ world: create }); const pendingWorld = getWorld(); + const pendingLazyWorld = getWorldLazy(); const closingWorld = closeWorld(); finishStart(); await expect(pendingWorld).resolves.toBe(first); + await expect(pendingLazyWorld).resolves.toBe(first); await closingWorld; expect(firstClose).toHaveBeenCalledOnce(); await expect(getWorld()).resolves.toBe(second); diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index 7bbe04b954..eeab8b3b7a 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -31,6 +31,12 @@ const globalSymbols: typeof globalThis & { [WorldCachePromise]?: Promise; } = globalThis; +function getWorkflowConfig() { + return boundWorkflowConfig ?? getRuntimeWorkflowConfig() ?? {}; +} + +setWorkflowQueueNamespace(getWorkflowConfig().queue?.namespace); + // Dynamic import for custom world modules. Uses a standard import() // wrapped in a try/catch with require() fallback for CJS test runners. // Note: the previous `new Function('specifier', 'return import(specifier)')` @@ -133,8 +139,7 @@ type ResolvedWorld = | { type: 'legacy'; world: World }; async function resolveWorld(): Promise { - const config = boundWorkflowConfig ?? getRuntimeWorkflowConfig() ?? {}; - setWorkflowQueueNamespace(config.queue?.namespace); + const config = getWorkflowConfig(); if (process.env.WORKFLOW_TARGET_WORLD) { return { diff --git a/packages/nest/package.json b/packages/nest/package.json index d498e9aff2..47c8a3142e 100644 --- a/packages/nest/package.json +++ b/packages/nest/package.json @@ -34,8 +34,6 @@ "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 d03c0be5f2..817919536a 100644 --- a/packages/nest/src/builder.ts +++ b/packages/nest/src/builder.ts @@ -1,8 +1,6 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, writeFile, readFile } from 'node:fs/promises'; import { BaseBuilder, createBaseBuilderConfig } from '@workflow/builders'; -import type { SourcemapMode } from '@workflow/config'; -import type { LoadedWorkflowConfig } from '@workflow/config/load'; -import { join, resolve } from 'pathe'; +import { join } from 'pathe'; import { rewriteTsImportsInContent } from './cjs-rewrite.js'; export interface NestBuilderOptions { @@ -16,14 +14,6 @@ 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' @@ -56,13 +46,9 @@ export interface NestBuilderOptions { * `'linked'`, `'external'`, `'both'`, or `false` to omit source maps. * Can also be set via the `WORKFLOW_SOURCEMAP` environment variable. */ - sourcemap?: SourcemapMode; + sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both'; } -type NestLocalBuilderOptions = NestBuilderOptions & { - workflowConfig?: LoadedWorkflowConfig; -}; - export class NestLocalBuilder extends BaseBuilder { #outDir: string; #moduleType: 'es6' | 'commonjs'; @@ -70,27 +56,16 @@ export class NestLocalBuilder extends BaseBuilder { #dirs: string[]; #workingDir: string; - constructor(options: NestLocalBuilderOptions = {}) { - const config = options.workflowConfig?.config; - const integration = - config?.integration?.type === 'nest' ? config.integration : undefined; - const build = config?.build; + constructor(options: NestBuilderOptions = {}) { const workingDir = options.workingDir ?? process.cwd(); - const outDir = resolve( - workingDir, - options.outDir ?? integration?.outDir ?? '.nestjs/workflow' - ); - const dirs = options.dirs ?? build?.dirs ?? ['src']; - const projectRoot = options.projectRoot ?? build?.projectRoot; + const outDir = options.outDir ?? join(workingDir, '.nestjs/workflow'); + const dirs = options.dirs ?? ['src']; super({ ...createBaseBuilderConfig({ workingDir, - watch: options.watch ?? integration?.watch ?? false, + watch: options.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', @@ -99,8 +74,8 @@ export class NestLocalBuilder extends BaseBuilder { webhookBundlePath: join(outDir, 'webhook.mjs'), }); this.#outDir = outDir; - this.#moduleType = options.moduleType ?? integration?.moduleType ?? 'es6'; - this.#distDir = options.distDir ?? integration?.distDir ?? 'dist'; + this.#moduleType = options.moduleType ?? 'es6'; + this.#distDir = options.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 69c5e634d1..a040bace25 100644 --- a/packages/nest/src/workflow.controller.ts +++ b/packages/nest/src/workflow.controller.ts @@ -5,17 +5,12 @@ 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, - publicManifest = process.env.WORKFLOW_PUBLIC_MANIFEST === '1' -): void { +export function configureWorkflowController(outDir: string): void { configuredOutDir = outDir; - exposePublicManifest = publicManifest; } /** @@ -117,7 +112,7 @@ export class WorkflowController { @Get('manifest.json') async handleManifest(@Res() res: any) { - if (!exposePublicManifest) { + if (process.env.WORKFLOW_PUBLIC_MANIFEST !== '1') { 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 deleted file mode 100644 index 29e6d77078..0000000000 --- a/packages/nest/src/workflow.module.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 56b6183d0e..f6578fed68 100644 --- a/packages/nest/src/workflow.module.ts +++ b/packages/nest/src/workflow.module.ts @@ -1,17 +1,11 @@ import { type DynamicModule, - Inject, Module, type OnModuleDestroy, type OnModuleInit, } from '@nestjs/common'; import { createBuildQueue } from '@workflow/builders'; -import { - createRuntimeWorkflowConfig, - loadWorkflowConfig, -} from '@workflow/config/load'; -import { setRuntimeWorkflowConfig } from '@workflow/config/runtime'; -import { closeWorld } from '@workflow/core/runtime'; +import { join } from 'pathe'; import { type NestBuilderOptions, NestLocalBuilder } from './builder.js'; import { configureWorkflowController, @@ -26,19 +20,17 @@ 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. @@ -52,6 +44,20 @@ 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], @@ -66,37 +72,14 @@ export class WorkflowModule implements OnModuleInit, OnModuleDestroy { } async onModuleInit() { - 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()); + const builder = WorkflowModule.builder; + if (builder) { + await WorkflowModule.buildQueue(() => builder.build()); + } } async onModuleDestroy() { - await closeWorld(); - setRuntimeWorkflowConfig(undefined); + // Cleanup if needed + WorkflowModule.builder = null; } } diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 5a5f997644..429c52c1d4 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -241,11 +241,7 @@ describe('withWorkflow builder config', () => { sourcemap: false, manifest: { public: true, output: 'custom-manifest.json' } }, - queue: { namespace: 'myapp' }, - integration: { - type: 'next', - local: { port: 4321 } - } + queue: { namespace: 'myapp' } };` ); process.env.PORT = '9876'; diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 96bc09168c..9a3fa41376 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -361,10 +361,6 @@ export function withWorkflow( }); 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) { @@ -374,11 +370,6 @@ export function withWorkflow( 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'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fd3bebe1f..5b2b99a669 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -763,12 +763,6 @@ 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 From 84d7d57f15298de10c75b09b7794e6c7fa2c4cf3 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:29:58 -0700 Subject: [PATCH 13/15] Fix Nitro runtime config bundling Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/nitro/src/index.test.ts | 13 +++++++++++++ packages/nitro/src/index.ts | 23 ++++++++++++++--------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts index c7f8211e6f..420ac2c0cf 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -159,10 +159,13 @@ describe('@workflow/nitro virtual handlers', () => { const source = nitro.options.virtual['#workflow/workflows.mjs']; const assignment = 'globalThis[Symbol.for("@workflow/config/runtime")] = workflowConfig;'; + const namespaceAssignment = + 'globalThis[Symbol.for("@workflow/queue/namespace")] = workflowConfig.queue?.namespace;'; expect(source).toContain( 'import workflowConfig from "@workflow/config/runtime-binding";' ); expect(source).toContain(assignment); + expect(source).toContain(namespaceAssignment); expect(source.indexOf(assignment)).toBeLessThan( source.indexOf('import(currentImportPath)') ); @@ -217,6 +220,16 @@ describe('@workflow/nitro workflow.config.ts', () => { handler.route === '/.well-known/workflow/v1/manifest.json' ) ).toBe(true); + const source = nitro.options.virtual['#workflow/workflows.mjs']; + expect(source).toContain( + 'import workflowConfig from "@workflow/config/runtime-binding";' + ); + expect(source).toContain( + 'globalThis[Symbol.for("@workflow/config/runtime")] = workflowConfig;' + ); + expect(source).toContain( + 'globalThis[Symbol.for("@workflow/queue/namespace")] = workflowConfig.queue?.namespace;' + ); }); it('prefers environment variables over workflow.config.ts', async () => { diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index bd623ffe33..26683d7aeb 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -69,10 +69,13 @@ export const nitroModule = { if (runtimeConfigPath) { plugins.push({ name: 'workflow:runtime-config', - resolveId(source: string) { - return source === '@workflow/config/runtime-binding' - ? runtimeConfigPath - : null; + resolveId: { + order: 'pre', + handler(source: string) { + return source === '@workflow/config/runtime-binding' + ? { id: runtimeConfigPath, external: false } + : null; + }, }, }); } @@ -420,15 +423,15 @@ function addVirtualHandler( const handlerImportPath = JSON.stringify( join(nitro.options.buildDir, buildPath) ); - - if (nitro.options.dev) { - const runtimeConfigSetup = hasRuntimeConfig - ? ` + const runtimeConfigSetup = hasRuntimeConfig + ? ` import workflowConfig from "@workflow/config/runtime-binding"; globalThis[Symbol.for("@workflow/config/runtime")] = workflowConfig; + globalThis[Symbol.for("@workflow/queue/namespace")] = workflowConfig.queue?.namespace; ` - : ''; + : ''; + if (nitro.options.dev) { // 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. @@ -499,6 +502,7 @@ function addVirtualHandler( if (!nitro.routing) { // Nitro v2 (legacy) nitro.options.virtual[`#${buildPath}`] = /* js */ ` + ${runtimeConfigSetup} import ${handlerImportPath}; import { fromWebHandler } from "h3"; import { POST } from ${handlerImportPath}; @@ -507,6 +511,7 @@ function addVirtualHandler( } else { // Nitro v3+ (native web handlers) nitro.options.virtual[`#${buildPath}`] = /* js */ ` + ${runtimeConfigSetup} import ${handlerImportPath}; import { POST } from ${handlerImportPath}; export default async ({ req }) => { From a1dc1a1e070a3123bd226ccd63a8b7ad40408562 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:37:16 -0700 Subject: [PATCH 14/15] Avoid config writes during Next startup Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/next/src/index.test.ts | 31 +++++++++++++++++++++++++++---- packages/next/src/index.ts | 19 +++++++++++++++---- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 429c52c1d4..58adba6668 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -139,12 +139,35 @@ describe('withWorkflow builder config', () => { ); }); - it('does not prewarm the SWC plugin cache for the production server', async () => { - const config = withWorkflow({}); + it('does not load build configuration for the production server', async () => { + const projectDir = mkdtempSync(join(realTmpDir, 'workflow-next-start-')); + process.chdir(projectDir); + writeFile( + join(projectDir, 'workflow.config.ts'), + `export default { world: './workflow.world.ts' };` + ); + writeFile( + join(projectDir, 'workflow.world.ts'), + 'export default () => {};' + ); - await config('phase-production-server', { defaultConfig: {} }); + try { + const config = withWorkflow({}); + await config('phase-production-server', { defaultConfig: {} }); - expect(prewarmWorkflowSwcPluginCacheMock).not.toHaveBeenCalled(); + expect(prewarmWorkflowSwcPluginCacheMock).not.toHaveBeenCalled(); + expect(getNextBuilderMock).not.toHaveBeenCalled(); + expect(process.env.WORKFLOW_TARGET_WORLD).toBeUndefined(); + expect(process.env.WORKFLOW_LOCAL_DATA_DIR).toBe('.next/workflow-data'); + expect( + existsSync( + join(projectDir, 'node_modules/.cache/workflow/runtime-config.mjs') + ) + ).toBe(false); + } finally { + process.chdir(originalCwd); + rmSync(projectDir, { recursive: true, force: true }); + } }); it('configures diagnostics inside the default Next.js dist dir', async () => { diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 9a3fa41376..1a120fcf1f 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -340,6 +340,20 @@ export function withWorkflow( phase: string, ctx: { defaultConfig: NextConfig } ) { + if (phase === 'phase-production-server') { + if (!process.env.VERCEL_DEPLOYMENT_ID) { + 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}`; + } + } + + return typeof nextConfigOrFn === 'function' + ? await nextConfigOrFn(phase, ctx) + : nextConfigOrFn; + } + if ( phase === 'phase-development-server' || phase === 'phase-production-build' @@ -606,10 +620,7 @@ export function withWorkflow( }; // only run this in the main process so it only runs once // as Next.js uses child processes for different builds - if ( - !process.env.WORKFLOW_NEXT_PRIVATE_BUILT && - phase !== 'phase-production-server' - ) { + if (!process.env.WORKFLOW_NEXT_PRIVATE_BUILT) { const workflowBuilder = await getWorkflowBuilder(); await workflowBuilder.build(); From 4eb0665c8cc61dee2fd156ded1bca5a721f63b2b Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:08:40 -0700 Subject: [PATCH 15/15] Preserve standalone workflow discovery Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .../src/lib/config/workflow-config.test.ts | 30 +++++++++++++++++++ .../cli/src/lib/config/workflow-config.ts | 4 ++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/lib/config/workflow-config.test.ts diff --git a/packages/cli/src/lib/config/workflow-config.test.ts b/packages/cli/src/lib/config/workflow-config.test.ts new file mode 100644 index 0000000000..e5f2a1f8e6 --- /dev/null +++ b/packages/cli/src/lib/config/workflow-config.test.ts @@ -0,0 +1,30 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { getWorkflowConfig } from './workflow-config.js'; + +describe('getWorkflowConfig', () => { + const originalCwd = process.env.WORKFLOW_OBSERVABILITY_CWD; + const workingDir = mkdtempSync(join(tmpdir(), 'workflow-cli-config-')); + + afterEach(() => { + if (originalCwd === undefined) { + delete process.env.WORKFLOW_OBSERVABILITY_CWD; + } else { + process.env.WORKFLOW_OBSERVABILITY_CWD = originalCwd; + } + rmSync(workingDir, { recursive: true, force: true }); + }); + + it('scans the project by default and honors configured directories', async () => { + process.env.WORKFLOW_OBSERVABILITY_CWD = workingDir; + expect((await getWorkflowConfig()).dirs).toEqual(['.']); + + writeFileSync( + join(workingDir, 'workflow.config.ts'), + `export default { build: { dirs: ['jobs'] } };` + ); + expect((await getWorkflowConfig()).dirs).toEqual(['jobs']); + }); +}); diff --git a/packages/cli/src/lib/config/workflow-config.ts b/packages/cli/src/lib/config/workflow-config.ts index 8ea0c0cc53..f8450b94e6 100644 --- a/packages/cli/src/lib/config/workflow-config.ts +++ b/packages/cli/src/lib/config/workflow-config.ts @@ -37,7 +37,9 @@ export const getWorkflowConfig = async ( }); const fileConfig = loadedConfig.config; const config: WorkflowConfig = { - dirs: fileConfig.build?.dirs ?? ['./workflows'], + dirs: + fileConfig.build?.dirs ?? + (buildTarget === 'standalone' ? ['.'] : ['./workflows']), workingDir, projectRoot: fileConfig.build?.projectRoot ? resolve(workingDir, fileConfig.build.projectRoot)