From d30869cdb9c0b787ef9877ac03101745d4ca65b4 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/28] 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 | 42 +++-- packages/builders/src/config-helpers.ts | 3 + packages/builders/src/constants.test.ts | 16 +- packages/builders/src/constants.ts | 30 ++-- .../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, 1871 insertions(+), 232 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 07fe97d24c..81a0c31621 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 c8ac9a9781..3702f7de79 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -35,6 +35,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'; @@ -239,6 +240,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)]; + } + protected logBaseBuilderInfo(...args: unknown[]): void { buildLogger.debug(args.map(String).join(' ')); } @@ -1371,11 +1382,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')) { @@ -1446,6 +1457,7 @@ export const __steps_registered = true; const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode( { + namespace: this.queueNamespace, routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt', } ); @@ -1503,6 +1515,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo keepNames: true, minify: false, external: ['@aws-sdk/credential-provider-web-identity'], + plugins: this.runtimeConfigPlugins, }); this.logEsbuildMessages( @@ -1641,6 +1654,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo const stepsRelativePath = `./${basename(stepsOutfile).replace(/\\/g, '/')}`; const escapedVMCode = workflowVMCode.replace(/[\\`$]/g, '\\$&'); const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode({ + namespace: this.queueNamespace, routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt', }); @@ -1690,6 +1704,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( @@ -1716,6 +1731,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo const escaped = interimBundleText.replace(/[\\`$]/g, '\\$&'); const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode( { + namespace: this.queueNamespace, routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt', } ); @@ -1972,6 +1988,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'); @@ -2108,10 +2125,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' + ); } /** @@ -2160,12 +2180,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 3e086100f9..cb8af5006e 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 7d063890e8..ebb9d606a3 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) @@ -82,4 +80,4 @@ export function createWorkflowEntrypointOptionsCode(options?: { /** * 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 ea21569a6c..f94e0c720f 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 { @@ -38,7 +38,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 cf54b68e17..08ed24b2b0 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 3f741982d8..ffe390227d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -95,6 +95,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 49b2dd5281..e48aaaacae 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 e62c8be2e1..4e08f94fdc 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 3c5bbe29f3..74199d4d0c 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 e1abf651d1..2eb974c9ac 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 eec4f423cf..bc78a93c8a 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 1b296d155a..d73b20506e 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 @@ -1282,6 +1325,9 @@ importers: '@workflow/cli': specifier: workspace:* version: link:../cli + '@workflow/config': + specifier: workspace:* + version: link:../config '@workflow/core': specifier: workspace:* version: link:../core @@ -16507,6 +16553,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 @@ -36363,7 +36410,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 2ce4f5d7e13035591bda46f48e88973a2411b48a 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/28] 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 3702f7de79..6f78219a50 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -241,7 +241,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[] { @@ -2125,13 +2128,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; } /** @@ -2180,16 +2184,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 217c41bd5c9e4c905637a1d6941ff4b647dca48b 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/28] 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 f83c6b9824960970a75c1289f1746643a0677958 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/28] 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 | 20 +-- .../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, 414 insertions(+), 691 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 6f78219a50..2a02ffdebb 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -35,7 +35,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'; @@ -248,9 +247,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 }) + ); + }, + }, + ]; } protected logBaseBuilderInfo(...args: unknown[]): void { diff --git a/packages/builders/src/constants.test.ts b/packages/builders/src/constants.test.ts index cb8af5006e..24fd9f9bb2 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 ebb9d606a3..774f9ace96 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 { @@ -80,4 +68,4 @@ export function createWorkflowEntrypointOptionsCode(options?: { /** * 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 4e08f94fdc..fec8cd0afc 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 d73b20506e..e6a4b0be3b 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 @@ -16553,7 +16559,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 @@ -36410,7 +36415,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 f671e3b65964ea5d1e3ecc4639cd75ff15254b7a 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/28] 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 d5c5d419b61f1d4754131861d787219e774d5439 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/28] 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 2b420952c9629531a6808b03f58a77bc7413a046 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/28] 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 4ae06161d5b5a7f67d4783c7848dd0223ee23142 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/28] Simplify config integration plumbing Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/builders/src/constants.ts | 6 +----- packages/nitro/src/builders.ts | 25 ++++++------------------- packages/nitro/src/index.ts | 6 +----- packages/nitro/src/vite.ts | 4 ++-- 4 files changed, 10 insertions(+), 31 deletions(-) diff --git a/packages/builders/src/constants.ts b/packages/builders/src/constants.ts index 774f9ace96..5fa039b8fc 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, 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 c3d29df699de416a1edaa4b915cc3c3f817ba233 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/28] 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 79843151d5f6457addb7d60a38fcf8d8f95aa30e 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/28] 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 | 2 +- packages/builders/src/constants.test.ts | 4 +- packages/builders/src/constants.ts | 28 ++- .../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, 405 insertions(+), 349 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 2a02ffdebb..6c977803ed 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -247,7 +247,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 [ { diff --git a/packages/builders/src/constants.test.ts b/packages/builders/src/constants.test.ts index 24fd9f9bb2..3e086100f9 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 5fa039b8fc..7d063890e8 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) 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 f94e0c720f..a57cc9e343 100644 --- a/packages/builders/src/vercel-build-output-api.ts +++ b/packages/builders/src/vercel-build-output-api.ts @@ -38,7 +38,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 5811b2a4cf21529bc35beb75acc57a82df6ba7b8 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/28] 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 acc1527e0d926452cd077b8f24b8e4bf337041c7 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/28] 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 1c9db58487..61f9ab7862 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 fec8cd0afc..e62c8be2e1 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 e6a4b0be3b..9522725bf3 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 82c8ac6731cc18852bf94e55c840f3163832e494 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/28] 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 d6bf4fc162187b9ed00d0a0c5ce4ee1e3609866b 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/28] 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 b7b26864e1b425d70637fb3f33693744ea3be85a 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/28] 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) From ae3f5b71ac1270b6e5364c0a1157124707018677 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:53:31 -0700 Subject: [PATCH 16/28] Clarify World selection and configuration Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .../workflow-next/with-workflow.mdx | 10 ++- .../docs/v5/deploying/world/local-world.mdx | 21 +++-- .../v5/deploying/world/postgres-world.mdx | 28 +++--- .../docs/v5/deploying/world/vercel-world.mdx | 25 +++--- .../docs/v5/foundations/configuration.mdx | 89 ++++++++++++++++--- packages/config/src/load.test.ts | 25 ++++++ .../core/src/runtime/world-config.test.ts | 9 ++ packages/core/src/runtime/world.ts | 5 +- 8 files changed, 163 insertions(+), 49 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 c7059f9150..98ef46651d 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 @@ -77,9 +77,9 @@ Use the smallest directory that contains every workspace package imported by you ## Options -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`. +Use [`workflow.config.ts`](/docs/foundations/configuration) for World, queue, +and shared build 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"; @@ -126,7 +126,9 @@ which accepts the same values plus `'0'` / `'1'` as aliases for `false` / `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. +The `workflows.local` options only affect local development. When `world` is not +configured, the runtime automatically uses the Local World locally and the +Vercel World on Vercel. ## Exporting a Function diff --git a/docs/content/docs/v5/deploying/world/local-world.mdx b/docs/content/docs/v5/deploying/world/local-world.mdx index 126bb3589f..38fcac9696 100644 --- a/docs/content/docs/v5/deploying/world/local-world.mdx +++ b/docs/content/docs/v5/deploying/world/local-world.mdx @@ -60,16 +60,21 @@ Maximum number of concurrent queue workers. Default: `100` ### Programmatic configuration -{/* @skip-typecheck: incomplete code sample */} -```typescript title="workflow.config.ts" lineNumbers +Point `world` in `workflow.config.ts` at a provider module: + +```typescript title="workflow.world.ts" lineNumbers +import type { WorldProvider } from "workflow/config"; import { createLocalWorld } from "@workflow/world-local"; -const world = createLocalWorld({ - dataDir: "./custom-workflow-data", - port: 5173, - // baseUrl overrides port if set - baseUrl: "https://local.example.com:3000", -}); +const world: WorldProvider = () => + createLocalWorld({ + dataDir: "./custom-workflow-data", + port: 5173, + // baseUrl overrides port if set + baseUrl: "https://local.example.com:3000", + }); + +export default world; ``` ## Limitations diff --git a/docs/content/docs/v5/deploying/world/postgres-world.mdx b/docs/content/docs/v5/deploying/world/postgres-world.mdx index bc8f6a98c9..f87b6f3324 100644 --- a/docs/content/docs/v5/deploying/world/postgres-world.mdx +++ b/docs/content/docs/v5/deploying/world/postgres-world.mdx @@ -173,17 +173,21 @@ For higher worker concurrency, Graphile Worker recommends setting `maxPoolSize` ### Programmatic configuration -{/*@skip-typecheck: incomplete code sample*/} +Point `world` in `workflow.config.ts` at a provider module: -```typescript title="workflow.config.ts" lineNumbers +```typescript title="workflow.world.ts" lineNumbers +import type { WorldProvider } from "workflow/config"; import { createWorld } from "@workflow/world-postgres"; -const world = createWorld({ - connectionString: "postgres://user:password@host:5432/database", - jobPrefix: "myapp_", - queueConcurrency: 50, - maxPoolSize: 52, // overrides WORKFLOW_POSTGRES_MAX_POOL_SIZE -}); +const world: WorldProvider = () => + createWorld({ + connectionString: process.env.WORKFLOW_POSTGRES_URL!, + jobPrefix: "myapp_", + queueConcurrency: 50, + maxPoolSize: 52, // overrides WORKFLOW_POSTGRES_MAX_POOL_SIZE + }); + +export default world; ``` ## How It Works @@ -209,15 +213,17 @@ Ensure your deployment has: 1. Network access to your PostgreSQL database 2. Environment variables configured correctly -3. The `start()` function called on server initialization +3. The provider selected in `workflow.config.ts`, or `start()` called when you construct the World directly -The Postgres World is not compatible with Vercel deployments. On Vercel, workflows automatically use the [Vercel World](/worlds/vercel) with zero configuration. +The Postgres World is not compatible with Vercel deployments. Omit `world` to +select the [Vercel World](/worlds/vercel) automatically on Vercel, or select an +appropriate World inside your provider based on the deployment environment. ## Limitations -- **Requires long-running process** - Must call `start()` on server initialization; not compatible with serverless platforms +- **Requires long-running process** - Configured providers start automatically; directly constructed Worlds require `start()` during server initialization - **PostgreSQL infrastructure** - Requires a PostgreSQL database (self-hosted or managed) - **Not compatible with Vercel** - Use the [Vercel World](/worlds/vercel) for Vercel deployments diff --git a/docs/content/docs/v5/deploying/world/vercel-world.mdx b/docs/content/docs/v5/deploying/world/vercel-world.mdx index 5bbb91fe69..f63a1021c4 100644 --- a/docs/content/docs/v5/deploying/world/vercel-world.mdx +++ b/docs/content/docs/v5/deploying/world/vercel-world.mdx @@ -113,20 +113,23 @@ Custom base URL for the Vercel workflow API. Automatically detected. ### Programmatic configuration -{/*@skip-typecheck: incomplete code sample*/} +Point `world` in `workflow.config.ts` at a provider module: -```typescript title="workflow.config.ts" lineNumbers +```typescript title="workflow.world.ts" lineNumbers +import type { WorldProvider } from "workflow/config"; import { createVercelWorld } from "@workflow/world-vercel"; -const world = createVercelWorld({ - token: process.env.WORKFLOW_VERCEL_AUTH_TOKEN, - baseUrl: "https://api.vercel.com/v1/workflow", - projectConfig: { - projectId: "my-project", - teamId: "my-team", - environment: "production", - }, -}); +const world: WorldProvider = () => + createVercelWorld({ + token: process.env.WORKFLOW_VERCEL_AUTH_TOKEN, + projectConfig: { + projectId: "my-project", + teamId: "my-team", + environment: "production", + }, + }); + +export default world; ``` ## Versioning diff --git a/docs/content/docs/v5/foundations/configuration.mdx b/docs/content/docs/v5/foundations/configuration.mdx index 8d60a8bc10..fb44e3fad6 100644 --- a/docs/content/docs/v5/foundations/configuration.mdx +++ b/docs/content/docs/v5/foundations/configuration.mdx @@ -2,7 +2,7 @@ title: Configuration description: Configure Workflow SDK with a typed workflow.config.ts file. type: conceptual -summary: Configure the World, builds, and framework integration for your app. +summary: Configure the World, builds, queue namespace, and framework integration for your app. prerequisites: - /docs/foundations/workflows-and-steps related: @@ -22,12 +22,16 @@ const config = { dirs: ["workflows"], sourcemap: false, }, + queue: { + namespace: "myapp", + }, } satisfies WorkflowConfig; export default config; ``` -The World module default-exports a lazy provider: +The World module default-exports a lazy provider. Keep runtime credentials in +this module or environment variables rather than the build configuration: ```typescript title="workflow.world.ts" lineNumbers import type { WorldProvider } from "workflow/config"; @@ -62,13 +66,37 @@ From highest to lowest priority: ## World -`world` is a module specifier relative to `workflow.config.ts`, or a package -name. Its default export must satisfy `WorldProvider`. The provider runs only -when the application first needs its World. +`world` is an optional path relative to `workflow.config.ts`, or a package +specifier. Its module must default-export a `WorldProvider`. The module is +bundled during the build, but the provider runs only when the application first +needs its World. + +World selection follows this order: + +| Condition | Selected World | +| --- | --- | +| `WORKFLOW_TARGET_WORLD` is set | The built-in target or module named by the environment variable | +| `world` is configured | The configured provider | +| `VERCEL_DEPLOYMENT_ID` is set | Vercel World | +| Otherwise | Local World | + +You do not need a `workflow.world.ts` file when the automatic Local and Vercel +Worlds are sufficient. A config file may also omit `world` while configuring +build or queue settings. + +A missing configured module fails during build or development setup. If a +provider throws or returns no World, runtime startup fails; Workflow does not +silently switch to another backend. -## Worlds by Environment +## Deployment Environments -Choose a World inside the factory based on the runtime environment: +For local development and Vercel deployments, omit `world` to use the automatic +Local and Vercel Worlds. + +When environments need different custom Worlds or options, select them inside +the provider using an application environment variable set by each deployment. +Use a dedicated variable when you need to distinguish preview from production, +because both commonly use `NODE_ENV=production`. ```typescript title="workflow.world.ts" lineNumbers import type { WorldProvider } from "workflow/config"; @@ -76,8 +104,9 @@ import { createLocalWorld } from "@workflow/world-local"; import { createWorld as createPostgresWorld } from "@workflow/world-postgres"; const world: WorldProvider = () => { - switch (process.env.NODE_ENV) { + switch (process.env.DEPLOYMENT_ENV) { case "production": + case "preview": return createPostgresWorld({ connectionString: process.env.WORKFLOW_POSTGRES_URL!, }); @@ -85,20 +114,52 @@ const world: WorldProvider = () => { case "test": return createLocalWorld(); default: - throw new Error(`Unexpected NODE_ENV: ${process.env.NODE_ENV}`); + throw new Error( + `Unexpected DEPLOYMENT_ENV: ${process.env.DEPLOYMENT_ENV}`, + ); } }; export default world; ``` +Set `WORKFLOW_POSTGRES_URL` independently in preview and production to keep +credentials out of source control and point each deployment at the correct +database. + +## Build + +| Option | Type | Description | +| --- | --- | --- | +| `build.dirs` | `string[]` | Directories to scan for workflow code | +| `build.projectRoot` | `string` | Root for tracing, module resolution, and TypeScript configuration | +| `build.externalPackages` | `string[]` | Packages excluded from generated bundles | +| `build.sourcemap` | `boolean \| "inline" \| "linked" \| "external" \| "both"` | Source map mode for generated bundles | +| `build.manifest.public` | `boolean` | Expose the workflow manifest over HTTP | +| `build.manifest.output` | `string` | Write the workflow manifest to this path | + +## Queue Namespace + +`queue.namespace` prefixes generated workflow and step queues so multiple +Workflow applications can share infrastructure without colliding. It must be +lowercase alphanumeric and start with a letter. + ## Integration Settings -Use `integration` for framework-specific settings. Set `type` to the framework -you are configuring: +Use `integration` only for framework-specific settings. It is a discriminated +object, so settings for different integrations cannot be mixed. Nitro currently +supports integration settings: -{/* @skip-typecheck: mutually exclusive config fragments */} +```typescript title="workflow.config.ts" lineNumbers +import type { WorkflowConfig } from "workflow/config"; -```typescript -integration: { type: "nitro", typescriptPlugin: true, runtime: "nodejs24.x" } +const config = { + integration: { + type: "nitro", + typescriptPlugin: true, + runtime: "nodejs24.x", + }, +} satisfies WorkflowConfig; + +export default config; ``` diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index f28876e949..e6ac8bc122 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -37,6 +37,31 @@ afterEach(() => { }); describe('loadWorkflowConfig', () => { + it('returns empty config when no config file exists', async () => { + const project = createProject({}); + + await expect(loadWorkflowConfig({ cwd: project })).resolves.toEqual({ + path: undefined, + runtimePath: undefined, + config: {}, + }); + }); + + it('allows config without a World provider', async () => { + const project = createProject({ + 'workflow.config.ts': `export default { queue: { namespace: 'app' } };`, + }); + const loaded = await loadWorkflowConfig({ cwd: project }); + const runtime = (await import( + pathToFileURL(loaded.runtimePath as string).href + )) as { default: RuntimeWorkflowConfig }; + + expect(runtime.default).toEqual({ + world: undefined, + queue: { namespace: 'app' }, + }); + }); + it('loads the nearest TypeScript config without merging parents', async () => { const project = createProject({ 'workflow.config.ts': `export default { build: { dirs: ['parent'] } };`, diff --git a/packages/core/src/runtime/world-config.test.ts b/packages/core/src/runtime/world-config.test.ts index f50ec13245..32174ccd19 100644 --- a/packages/core/src/runtime/world-config.test.ts +++ b/packages/core/src/runtime/world-config.test.ts @@ -74,6 +74,15 @@ describe('configured World', () => { expect(create).toHaveBeenCalledTimes(2); }); + it('rejects a configured provider that returns no World', async () => { + delete process.env.WORKFLOW_TARGET_WORLD; + setRuntimeWorkflowConfig({ world: () => undefined as never }); + + await expect(getWorld()).rejects.toThrow( + 'Configured World provider must return a World.' + ); + }); + it('does not cache a World closed during startup', async () => { delete process.env.WORKFLOW_TARGET_WORLD; let finishStart!: () => void; diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index eeab8b3b7a..7bb4adc5c2 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import { getRuntimeWorkflowConfig } from '@workflow/config/runtime'; @@ -149,9 +150,11 @@ async function resolveWorld(): Promise { } if (config.world) { + const world = await config.world(); + assert(world, 'Configured World provider must return a World.'); return { type: 'configured', - world: await config.world(), + world, }; } From 8e34ed636d429d5330673c4926c71997411271fa Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:25:24 -0700 Subject: [PATCH 17/28] Fix config-backed runtime selection Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .../docs/v5/deploying/world/local-world.mdx | 7 +- .../v5/deploying/world/postgres-world.mdx | 143 +++++------------- .../docs/v5/deploying/world/vercel-world.mdx | 19 +-- .../docs/v5/foundations/configuration.mdx | 6 +- packages/builders/src/base-builder.ts | 13 +- packages/builders/src/standalone.ts | 1 + .../src/vercel-build-output-api.test.ts | 18 +++ .../builders/src/vercel-build-output-api.ts | 6 + packages/cli/src/commands/health.ts | 40 ++--- .../cli/src/lib/config/workflow-config.ts | 28 ++-- packages/cli/src/lib/inspect/env.ts | 2 +- packages/cli/src/lib/inspect/flags.ts | 2 - packages/cli/src/lib/inspect/setup.test.ts | 65 ++++++++ packages/cli/src/lib/inspect/setup.ts | 49 ++++-- packages/config/src/load.test.ts | 6 + packages/config/src/load.ts | 2 +- packages/nitro/src/index.test.ts | 25 +-- packages/nitro/src/index.ts | 4 +- 18 files changed, 218 insertions(+), 218 deletions(-) create mode 100644 packages/builders/src/vercel-build-output-api.test.ts create mode 100644 packages/cli/src/lib/inspect/setup.test.ts diff --git a/docs/content/docs/v5/deploying/world/local-world.mdx b/docs/content/docs/v5/deploying/world/local-world.mdx index 38fcac9696..1092226105 100644 --- a/docs/content/docs/v5/deploying/world/local-world.mdx +++ b/docs/content/docs/v5/deploying/world/local-world.mdx @@ -20,7 +20,9 @@ WORKFLOW_TARGET_WORLD=local ## Observability -The `workflow` CLI uses the local world by default. Running these commands inside your workflow project will show your local development workflows: +When no World is configured, the `workflow` CLI uses the local world by default. +Running these commands inside your workflow project will show your local +development workflows: ```bash # List recent workflow runs @@ -86,4 +88,5 @@ The local world is designed for development, not production: - **Single instance** - Cannot handle distributed deployments - **No authentication** - Suitable only for local development -For production deployments, use the [Vercel World](/worlds/vercel) or [Postgres World](/worlds/postgres). +For production deployments, use the [Vercel World](/docs/deploying/world/vercel-world) +or [Postgres World](/docs/deploying/world/postgres-world). diff --git a/docs/content/docs/v5/deploying/world/postgres-world.mdx b/docs/content/docs/v5/deploying/world/postgres-world.mdx index f87b6f3324..88fe12f702 100644 --- a/docs/content/docs/v5/deploying/world/postgres-world.mdx +++ b/docs/content/docs/v5/deploying/world/postgres-world.mdx @@ -22,104 +22,50 @@ Install the Postgres World package in your workflow project: @workflow/world-postgres ``` -Configure the required environment variables to use the world and point it to your PostgreSQL database: +Create a provider for the World: -```bash title=".env" -WORKFLOW_TARGET_WORLD="@workflow/world-postgres" -WORKFLOW_POSTGRES_URL="postgres://user:password@host:5432/database" -``` - -Run the migration script to create the necessary tables in your database. Ensure `WORKFLOW_POSTGRES_URL` is set when running this command: - -```bash -npx workflow-postgres-setup -``` - - -The migration is idempotent and can safely be run as a post-deployment lifecycle script. - - -## Starting the World - -To subscribe to the graphile-worker queue, your workflow app needs to start the world on server start. Here are examples for a few frameworks: - - - - - -Create an `instrumentation.ts` file in your project root: - -```ts title="instrumentation.ts" lineNumbers -export async function register() { - if (process.env.NEXT_RUNTIME !== "edge") { - const { getWorld } = await import("workflow/runtime"); - const world = await getWorld(); - await world.start?.(); - } -} -``` - - -Learn more about [Next.js Instrumentation](https://nextjs.org/docs/app/guides/instrumentation). - - - - - - -Create a `src/hooks.server.ts` file: +```typescript title="workflow.world.ts" lineNumbers +import type { WorldProvider } from "workflow/config"; +import { createWorld } from "@workflow/world-postgres"; -```ts title="src/hooks.server.ts" lineNumbers -import type { ServerInit } from "@sveltejs/kit"; +const world: WorldProvider = () => + createWorld({ + connectionString: process.env.WORKFLOW_POSTGRES_URL!, + }); -export const init: ServerInit = async () => { - const { getWorld } = await import("workflow/runtime"); - const world = await getWorld(); - await world.start?.(); -}; +export default world; ``` - -Learn more about [SvelteKit Hooks](https://svelte.dev/docs/kit/hooks). - - - - - +Select it from your Workflow config: -Create a plugin to start the world on server initialization: +```typescript title="workflow.config.ts" lineNumbers +import type { WorkflowConfig } from "workflow/config"; -```ts title="plugins/start-pg-world.ts" lineNumbers -import { defineNitroPlugin } from "nitro/~internal/runtime/plugin"; +const config = { + world: "./workflow.world.ts", +} satisfies WorkflowConfig; -export default defineNitroPlugin(async () => { - const { getWorld } = await import("workflow/runtime"); - const world = await getWorld(); - await world.start?.(); -}); +export default config; ``` -Register the plugin in your config: - -```ts title="nitro.config.ts" -import { defineNitroConfig } from "nitropack"; +Run the migration script to create the necessary tables in your database. Ensure `WORKFLOW_POSTGRES_URL` is set when running this command: -export default defineNitroConfig({ - modules: ["workflow/nitro"], - plugins: ["plugins/start-pg-world.ts"], -}); +```bash +npx workflow-postgres-setup ``` -Learn more about [Nitro Plugins](https://v3.nitro.build/docs/plugins). +The migration is idempotent and can safely be run as a post-deployment lifecycle script. - +## Starting the World - +Workflow calls `start()` once, immediately before the configured World is first +used. If you construct a Postgres World outside the provider, call `start()` +yourself during server initialization. -The Postgres World requires a long-lived worker process that polls the database for jobs. This does not work on serverless environments. For Vercel deployments, use the [Vercel World](/worlds/vercel) instead. +The Postgres World requires a long-lived worker process that polls the database for jobs. This does not work on serverless environments. For Vercel deployments, use the [Vercel World](/docs/deploying/world/vercel-world) instead. ## Observability @@ -127,14 +73,11 @@ The Postgres World requires a long-lived worker process that polls the database Use the `workflow` CLI to inspect workflows stored in PostgreSQL: ```bash -# Set your database URL -export WORKFLOW_POSTGRES_URL="postgres://user:password@host:5432/database" - # List workflow runs -npx workflow inspect runs --backend @workflow/world-postgres +npx workflow inspect runs # Launch the web UI -npx workflow web --backend @workflow/world-postgres +npx workflow web ``` If `WORKFLOW_POSTGRES_URL` is not set, the CLI defaults to `postgres://world:world@localhost:5432/world`. @@ -149,9 +92,9 @@ Learn more in the [Observability](/docs/observability) documentation. All configuration options can be set via environment variables or programmatically via `createWorld()`. -### `WORKFLOW_POSTGRES_URL` (required) +### `WORKFLOW_POSTGRES_URL` -PostgreSQL connection string. Falls back to `DATABASE_URL` if not set. +PostgreSQL connection string. Default: `postgres://world:world@localhost:5432/world` @@ -171,25 +114,6 @@ Maximum size of the internal `pg.Pool` used when `createWorld()` constructs the For higher worker concurrency, Graphile Worker recommends setting `maxPoolSize` to `10` or `queueConcurrency + 2`, whichever is larger. -### Programmatic configuration - -Point `world` in `workflow.config.ts` at a provider module: - -```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, // overrides WORKFLOW_POSTGRES_MAX_POOL_SIZE - }); - -export default world; -``` - ## How It Works The Postgres World uses PostgreSQL as a durable backend: @@ -213,11 +137,11 @@ Ensure your deployment has: 1. Network access to your PostgreSQL database 2. Environment variables configured correctly -3. The provider selected in `workflow.config.ts`, or `start()` called when you construct the World directly +3. The provider selected in `workflow.config.ts` The Postgres World is not compatible with Vercel deployments. Omit `world` to -select the [Vercel World](/worlds/vercel) automatically on Vercel, or select an +select the [Vercel World](/docs/deploying/world/vercel-world) automatically on Vercel, or select an appropriate World inside your provider based on the deployment environment. @@ -225,6 +149,7 @@ appropriate World inside your provider based on the deployment environment. - **Requires long-running process** - Configured providers start automatically; directly constructed Worlds require `start()` during server initialization - **PostgreSQL infrastructure** - Requires a PostgreSQL database (self-hosted or managed) -- **Not compatible with Vercel** - Use the [Vercel World](/worlds/vercel) for Vercel deployments +- **Not compatible with Vercel** - Use the [Vercel World](/docs/deploying/world/vercel-world) for Vercel deployments -For local development, use the [Local World](/worlds/local) which requires no external services. +For local development, use the [Local World](/docs/deploying/world/local-world) +which requires no external services. diff --git a/docs/content/docs/v5/deploying/world/vercel-world.mdx b/docs/content/docs/v5/deploying/world/vercel-world.mdx index f63a1021c4..46a27ee29f 100644 --- a/docs/content/docs/v5/deploying/world/vercel-world.mdx +++ b/docs/content/docs/v5/deploying/world/vercel-world.mdx @@ -40,7 +40,8 @@ For complete details on pricing, usage limits, and included allotments on Vercel - **[Vercel limits](https://vercel.com/docs/limits)** — Platform-wide limits including Workflow-specific constraints - **[Vercel Hobby plan](https://vercel.com/docs/plans/hobby)** — Free tier included usage for Workflow and other resources -For self-hosted deployments, use the [Postgres World](/worlds/postgres). For local development, use the [Local World](/worlds/local). +For self-hosted deployments, use the [Postgres World](/docs/deploying/world/postgres-world). +For local development, use the [Local World](/docs/deploying/world/local-world). ## Limitations @@ -91,22 +92,6 @@ Learn more in the [Observability](/docs/observability) documentation. The Vercel World requires no configuration when deployed to Vercel. For advanced use cases, you can override settings programmatically via `createVercelWorld()`. -### `WORKFLOW_VERCEL_ENV` - -The Vercel environment to use. Options: `production`, `preview`, `development`. Automatically detected. - -### `WORKFLOW_VERCEL_AUTH_TOKEN` - -Authentication token for API requests. Automatically detected. - -### `WORKFLOW_VERCEL_PROJECT` - -Vercel project ID for API requests. Automatically detected. - -### `WORKFLOW_VERCEL_TEAM` - -Vercel team ID for API requests. Automatically detected. - ### `WORKFLOW_VERCEL_BACKEND_URL` Custom base URL for the Vercel workflow API. Automatically detected. diff --git a/docs/content/docs/v5/foundations/configuration.mdx b/docs/content/docs/v5/foundations/configuration.mdx index fb44e3fad6..b1ed2c4601 100644 --- a/docs/content/docs/v5/foundations/configuration.mdx +++ b/docs/content/docs/v5/foundations/configuration.mdx @@ -104,7 +104,7 @@ import { createLocalWorld } from "@workflow/world-local"; import { createWorld as createPostgresWorld } from "@workflow/world-postgres"; const world: WorldProvider = () => { - switch (process.env.DEPLOYMENT_ENV) { + switch (process.env.APP_ENV) { case "production": case "preview": return createPostgresWorld({ @@ -115,7 +115,7 @@ const world: WorldProvider = () => { return createLocalWorld(); default: throw new Error( - `Unexpected DEPLOYMENT_ENV: ${process.env.DEPLOYMENT_ENV}`, + `Unexpected APP_ENV: ${process.env.APP_ENV}`, ); } }; @@ -133,7 +133,7 @@ database. | --- | --- | --- | | `build.dirs` | `string[]` | Directories to scan for workflow code | | `build.projectRoot` | `string` | Root for tracing, module resolution, and TypeScript configuration | -| `build.externalPackages` | `string[]` | Packages excluded from generated bundles | +| `build.externalPackages` | `string[]` | Packages excluded from generated bundles. Not supported by the direct `vercel-build-output-api` target | | `build.sourcemap` | `boolean \| "inline" \| "linked" \| "external" \| "both"` | Source map mode for generated bundles | | `build.manifest.public` | `boolean` | Expose the workflow manifest over HTTP | | `build.manifest.output` | `string` | Write the workflow manifest to this path | diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 6c977803ed..e7deb4ff72 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -1526,7 +1526,10 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo write: true, keepNames: true, minify: false, - external: ['@aws-sdk/credential-provider-web-identity'], + external: [ + '@aws-sdk/credential-provider-web-identity', + ...(this.config.externalPackages ?? []), + ], plugins: this.runtimeConfigPlugins, }); @@ -1715,7 +1718,10 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo keepNames: true, minify: false, define: importMetaDefine, - external: ['@aws-sdk/credential-provider-web-identity'], + external: [ + '@aws-sdk/credential-provider-web-identity', + ...(this.config.externalPackages ?? []), + ], plugins: this.runtimeConfigPlugins, }); this.logEsbuildMessages(finalResult, 'combined bundle', true); @@ -1998,8 +2004,7 @@ export const OPTIONS = handler;`; ], sourcemap: this.resolveSourcemap(EMIT_SOURCEMAPS_FOR_DEBUGGING), mainFields: ['module', 'main'], - // Don't externalize anything - bundle everything including workflow packages - external: [], + external: this.config.externalPackages ?? [], plugins: this.runtimeConfigPlugins, }); diff --git a/packages/builders/src/standalone.ts b/packages/builders/src/standalone.ts index 61f9ab7862..06ea4b0ce8 100644 --- a/packages/builders/src/standalone.ts +++ b/packages/builders/src/standalone.ts @@ -45,6 +45,7 @@ export class StandaloneBuilder extends BaseBuilder { await this.createWebhookBundle({ outfile: webhookBundlePath, + bundle: true, }); } } diff --git a/packages/builders/src/vercel-build-output-api.test.ts b/packages/builders/src/vercel-build-output-api.test.ts new file mode 100644 index 0000000000..d7fafa3828 --- /dev/null +++ b/packages/builders/src/vercel-build-output-api.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { VercelBuildOutputAPIBuilder } from './vercel-build-output-api.js'; + +it('rejects external packages because the target does not trace them', async () => { + const builder = new VercelBuildOutputAPIBuilder({ + buildTarget: 'vercel-build-output-api', + dirs: ['.'], + workingDir: process.cwd(), + externalPackages: ['database-client'], + stepsBundlePath: '', + workflowsBundlePath: '', + webhookBundlePath: '', + }); + + await expect(builder.build()).rejects.toThrow( + 'build.externalPackages is not supported by the vercel-build-output-api target.' + ); +}); diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts index a57cc9e343..aa70ec2a3c 100644 --- a/packages/builders/src/vercel-build-output-api.ts +++ b/packages/builders/src/vercel-build-output-api.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { copyFile, mkdir, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { BaseBuilder } from './base-builder.js'; @@ -5,6 +6,11 @@ import { createWorkflowQueueTrigger } from './constants.js'; export class VercelBuildOutputAPIBuilder extends BaseBuilder { async build(): Promise { + assert( + !this.config.externalPackages?.length, + 'build.externalPackages is not supported by the vercel-build-output-api target.' + ); + const outputDir = resolve(this.config.workingDir, '.vercel/output'); const functionsDir = join(outputDir, 'functions'); const workflowGeneratedDir = join(functionsDir, '.well-known/workflow/v1'); diff --git a/packages/cli/src/commands/health.ts b/packages/cli/src/commands/health.ts index c4249dd010..7c9ce019c6 100644 --- a/packages/cli/src/commands/health.ts +++ b/packages/cli/src/commands/health.ts @@ -162,7 +162,7 @@ async function verifyLocalServerAccessible( ); } -function isLocalBackend(backend: string): boolean { +function isLocalBackend(backend: string | undefined): boolean { return backend === 'local' || backend === '@workflow/world-local'; } @@ -263,43 +263,29 @@ export default class Health extends BaseCommand { public async run(): Promise { const { flags } = await this.parse(Health); - // For local backend, set up port configuration early - if (isLocalBackend(flags.backend)) { - // If user specifies a port, set the env var so the World uses it - if (flags.port) { - process.env.WORKFLOW_LOCAL_BASE_URL = `http://localhost:${flags.port}`; - } - // Set default WORKFLOW_LOCAL_BASE_URL if not already set - // We use WORKFLOW_LOCAL_BASE_URL instead of PORT to avoid conflicts - // with other tools (like Next.js) that also use the PORT env var - if (!process.env.WORKFLOW_LOCAL_BASE_URL && !process.env.PORT) { - process.env.WORKFLOW_LOCAL_BASE_URL = `http://localhost:${DEFAULT_LOCAL_PORT}`; - } + const world = await setupCliWorld(flags, this.config.version); + if (!world) { + throw new Error( + 'Failed to connect to backend. Check your configuration.' + ); + } - // Verify the server is accessible before proceeding + const backend = + flags.backend ?? process.env.WORKFLOW_TARGET_WORLD ?? 'configured World'; + if (isLocalBackend(backend)) { const accessible = await this.verifyLocalServer( flags.json, flags.verbose, flags.port ); - if (!accessible) { - process.exit(1); - } - } - - const world = await setupCliWorld(flags, this.config.version); - if (!world) { - throw new Error( - 'Failed to connect to backend. Check your configuration.' - ); + if (!accessible) process.exit(1); } const { healthCheck } = await import('@workflow/core/runtime'); const endpoints = getEndpointsToCheck(flags.endpoint); if (!flags.json) { - const backendName = - flags.backend === 'local' ? 'local server' : flags.backend; + const backendName = backend === 'local' ? 'local server' : backend; logger.log( chalk.gray(`Running queue-based health check against ${backendName}...`) ); @@ -312,7 +298,7 @@ export default class Health extends BaseCommand { verbose: flags.verbose, }); - this.outputResults(results, flags.json, flags.backend); + this.outputResults(results, flags.json, backend); const allHealthy = results.every((r) => r.healthy); process.exit(allHealthy ? 0 : 1); diff --git a/packages/cli/src/lib/config/workflow-config.ts b/packages/cli/src/lib/config/workflow-config.ts index f8450b94e6..b1055f908d 100644 --- a/packages/cli/src/lib/config/workflow-config.ts +++ b/packages/cli/src/lib/config/workflow-config.ts @@ -1,5 +1,8 @@ import { resolve } from 'node:path'; -import { loadWorkflowConfig } from '@workflow/config/load'; +import { + type LoadedWorkflowConfig, + loadWorkflowConfig, +} from '@workflow/config/load'; import { config as loadDotEnv } from 'dotenv'; import type { BuildTarget, WorkflowConfig } from './types.js'; @@ -13,6 +16,15 @@ export function resolveWorkflowCwd(): string { return resolve(process.cwd(), raw); } +export async function loadProjectWorkflowConfig( + configFile?: string +): Promise { + const cwd = resolveWorkflowCwd(); + loadDotEnv({ path: resolve(cwd, '.env.local'), quiet: true }); + loadDotEnv({ path: resolve(cwd, '.env'), quiet: true }); + return loadWorkflowConfig({ cwd, configFile }); +} + export const getWorkflowConfig = async ( options: { buildTarget?: BuildTarget; @@ -22,19 +34,7 @@ export const getWorkflowConfig = async ( ): Promise => { const { buildTarget = 'standalone', workflowManifest, configFile } = options; const workingDir = resolveWorkflowCwd(); - loadDotEnv({ - path: resolve(workingDir, '.env.local'), - quiet: true, - }); - loadDotEnv({ - path: resolve(workingDir, '.env'), - quiet: true, - }); - - const loadedConfig = await loadWorkflowConfig({ - cwd: workingDir, - configFile, - }); + const loadedConfig = await loadProjectWorkflowConfig(configFile); const fileConfig = loadedConfig.config; const config: WorkflowConfig = { dirs: diff --git a/packages/cli/src/lib/inspect/env.ts b/packages/cli/src/lib/inspect/env.ts index 5b6b2d8d92..25d68bc71f 100644 --- a/packages/cli/src/lib/inspect/env.ts +++ b/packages/cli/src/lib/inspect/env.ts @@ -20,7 +20,7 @@ import { * Note: WORKFLOW_VERCEL_* env vars are read back via getEnvVars() and passed * to createVercelWorld() explicitly — they are NOT read by createWorld(). */ -export const writeEnvVars = (envVars: Record) => { +export const writeEnvVars = (envVars: Record) => { Object.entries(envVars).forEach(([key, value]) => { if ( value === undefined || diff --git a/packages/cli/src/lib/inspect/flags.ts b/packages/cli/src/lib/inspect/flags.ts index 44fa08c767..a95148ce23 100644 --- a/packages/cli/src/lib/inspect/flags.ts +++ b/packages/cli/src/lib/inspect/flags.ts @@ -30,7 +30,6 @@ export const cliFlags = { description: 'backend to inspect', required: false, char: 'b', - default: 'local', env: 'WORKFLOW_TARGET_WORLD', helpGroup: 'Target', helpLabel: '-b, --backend', @@ -75,7 +74,6 @@ export const cliFlags = { description: 'If backend is vercel, the vercel environment to use', required: false, options: ['production', 'preview'], - default: 'production', char: 'e', dependsOn: ['backend'], env: 'WORKFLOW_VERCEL_ENV', diff --git a/packages/cli/src/lib/inspect/setup.test.ts b/packages/cli/src/lib/inspect/setup.test.ts new file mode 100644 index 0000000000..a91614aee6 --- /dev/null +++ b/packages/cli/src/lib/inspect/setup.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setRuntimeWorkflowConfig } from '@workflow/config/runtime'; +import { setWorld } from '@workflow/core/runtime'; +import type { World } from '@workflow/world'; +import { resolveQueueNamespace } from '@workflow/world/queue.js'; +import { afterEach, expect, it, vi } from 'vitest'; +import { setupCliWorld } from './setup.js'; + +vi.mock('../update-check.js', () => ({ + checkForUpdateCached: () => ({ needsUpdate: false }), +})); + +const project = mkdtempSync(join(tmpdir(), 'workflow-cli-world-')); +const originalCwd = process.env.WORKFLOW_OBSERVABILITY_CWD; +const originalTarget = process.env.WORKFLOW_TARGET_WORLD; + +afterEach(() => { + setWorld(undefined); + setRuntimeWorkflowConfig(undefined); + if (originalCwd === undefined) { + delete process.env.WORKFLOW_OBSERVABILITY_CWD; + } else { + process.env.WORKFLOW_OBSERVABILITY_CWD = originalCwd; + } + if (originalTarget === undefined) { + delete process.env.WORKFLOW_TARGET_WORLD; + } else { + process.env.WORKFLOW_TARGET_WORLD = originalTarget; + } + rmSync(project, { recursive: true, force: true }); +}); + +it('uses the configured World instead of the implicit local default', async () => { + writeFileSync( + join(project, 'workflow.config.ts'), + `export default { + world: './workflow.world.mjs', + queue: { namespace: 'configured' } + };` + ); + writeFileSync( + join(project, 'workflow.world.mjs'), + `export default () => ({ source: 'configured' });` + ); + process.env.WORKFLOW_OBSERVABILITY_CWD = project; + delete process.env.WORKFLOW_TARGET_WORLD; + + const world = await setupCliWorld( + { + json: true, + verbose: false, + env: 'production', + authToken: '', + project: '', + team: '', + }, + 'test' + ); + + expect((world as World & { source: string }).source).toBe('configured'); + expect(process.env.WORKFLOW_TARGET_WORLD).toBeUndefined(); + expect(resolveQueueNamespace()).toBe('configured'); +}); diff --git a/packages/cli/src/lib/inspect/setup.ts b/packages/cli/src/lib/inspect/setup.ts index 36922a3c51..74be39d3d9 100644 --- a/packages/cli/src/lib/inspect/setup.ts +++ b/packages/cli/src/lib/inspect/setup.ts @@ -1,3 +1,5 @@ +import { createRuntimeWorkflowConfig } from '@workflow/config/load'; +import { setRuntimeWorkflowConfig } from '@workflow/config/runtime'; import { createWorld, setWorld } from '@workflow/core/runtime'; import { isVercelWorldTarget } from '@workflow/utils'; import type { World } from '@workflow/world'; @@ -5,6 +7,7 @@ import { createVercelWorld } from '@workflow/world-vercel'; import chalk from 'chalk'; import terminalLink from 'terminal-link'; import { logger, setJsonMode, setVerboseMode } from '../config/log.js'; +import { loadProjectWorkflowConfig } from '../config/workflow-config.js'; import { checkForUpdateCached } from '../update-check.js'; import { inferLocalWorldEnvVars, @@ -13,20 +16,17 @@ import { writeEnvVars, } from './env.js'; -/** - * Setup CLI world configuration. - * If throwOnConfigError is false, will return null world with the error message - * instead of throwing, allowing the web UI to open for configuration. - */ +/** Set up the CLI World, allowing the web UI to ignore missing local data. */ export const setupCliWorld = async ( flags: { json: boolean; verbose: boolean; - backend: string; - env: string; + backend?: string; + env?: string; authToken: string; project: string; team: string; + port?: number; }, version: string, ignoreLocalWorldConfigError = false @@ -34,10 +34,24 @@ export const setupCliWorld = async ( setJsonMode(Boolean(flags.json)); setVerboseMode(Boolean(flags.verbose)); + const loadedConfig = await loadProjectWorkflowConfig(); + setRuntimeWorkflowConfig( + loadedConfig.path ? createRuntimeWorkflowConfig(loadedConfig) : undefined + ); + + const backend = + flags.backend ?? + process.env.WORKFLOW_TARGET_WORLD ?? + (loadedConfig.config.world + ? undefined + : process.env.VERCEL_DEPLOYMENT_ID + ? 'vercel' + : 'local'); + // Check for updates const updateCheck = await checkForUpdateCached(version); - const withAnsiLinks = flags.json ? false : true; + const withAnsiLinks = !flags.json; const docsUrl = withAnsiLinks ? terminalLink('https://workflow-sdk.dev/', 'https://workflow-sdk.dev/') : 'https://workflow-sdk.dev/'; @@ -73,14 +87,17 @@ export const setupCliWorld = async ( logger.showBox('green', ...boxLines); - logger.debug('Inferring env vars, backend:', flags.backend); + logger.debug( + 'Inferring env vars, backend:', + backend ?? loadedConfig.config.world + ); writeEnvVars({ DEBUG: flags.verbose ? '1' : '', - WORKFLOW_TARGET_WORLD: flags.backend, }); + if (backend) writeEnvVars({ WORKFLOW_TARGET_WORLD: backend }); let vercelEnvVars: VercelEnvVars | undefined; - if (isVercelWorldTarget(flags.backend)) { + if (backend && isVercelWorldTarget(backend)) { // Seed the initial flags into process.env so inferVercelEnvVars() can // read them via getEnvVars() as starting values before inference. writeEnvVars({ @@ -90,10 +107,12 @@ export const setupCliWorld = async ( WORKFLOW_VERCEL_TEAM: flags.team, }); vercelEnvVars = await inferVercelEnvVars(); - } else if ( - flags.backend === 'local' || - flags.backend === '@workflow/world-local' - ) { + } else if (backend === 'local' || backend === '@workflow/world-local') { + if (flags.port) { + writeEnvVars({ + WORKFLOW_LOCAL_BASE_URL: `http://localhost:${flags.port}`, + }); + } try { await inferLocalWorldEnvVars(); } catch (error) { diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index e6ac8bc122..ff824abd22 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -10,6 +10,10 @@ import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; import { loadWorkflowConfig } from './load.js'; +import { + getRuntimeWorkflowConfig, + setRuntimeWorkflowConfig, +} from './runtime.js'; import type { RuntimeWorkflowConfig } from './runtime-binding.js'; import { WorkflowConfigSchema } from './schema.js'; @@ -29,6 +33,7 @@ function createProject(files: Record): string { } afterEach(() => { + setRuntimeWorkflowConfig(undefined); delete (globalThis as { __workflowWorldImports?: number }) .__workflowWorldImports; for (const dir of tempDirs.splice(0)) { @@ -60,6 +65,7 @@ describe('loadWorkflowConfig', () => { world: undefined, queue: { namespace: 'app' }, }); + expect(getRuntimeWorkflowConfig()).toBe(runtime.default); }); it('loads the nearest TypeScript config without merging parents', async () => { diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 55ef3eebd0..1c92917ed9 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -138,7 +138,7 @@ export async function loadWorkflowConfig( mkdirSync(runtimeDir, { recursive: true }); writeFileSync( runtimePath, - `const world = ${worldFactory};\nexport default { world, queue: ${JSON.stringify(config.queue)} };\n` + `const world = ${worldFactory};\nconst config = { world, queue: ${JSON.stringify(config.queue)} };\nglobalThis[Symbol.for('@workflow/config/runtime')] = config;\nglobalThis[Symbol.for('@workflow/queue/namespace')] = config.queue?.namespace;\nexport default config;\n` ); return { path, runtimePath, config }; diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts index 420ac2c0cf..3465483f94 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -146,7 +146,7 @@ describe('@workflow/nitro virtual handlers', () => { expect(source).not.toContain('@workflow/config'); }); - it('installs runtime config before importing unbundled dev routes', async () => { + it('imports runtime config before unbundled dev routes', async () => { const project = createProject('export default {};'); const nitro = createNitroStub({ routing: false, @@ -157,16 +157,9 @@ describe('@workflow/nitro virtual handlers', () => { await nitroModule.setup(nitro); 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( + const binding = 'import "@workflow/config/runtime-binding";'; + expect(source).toContain(binding); + expect(source.indexOf(binding)).toBeLessThan( source.indexOf('import(currentImportPath)') ); }); @@ -221,15 +214,7 @@ describe('@workflow/nitro workflow.config.ts', () => { ) ).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;' - ); + expect(source).toContain('import "@workflow/config/runtime-binding";'); }); it('prefers environment variables over workflow.config.ts', async () => { diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index 26683d7aeb..4713f764bc 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -425,9 +425,7 @@ function addVirtualHandler( ); 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; + import "@workflow/config/runtime-binding"; ` : ''; From 17b30ae5798634889ebecefdeb8d7bf75f6e9f8c Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:53:37 -0700 Subject: [PATCH 18/28] Fix framework runtime config wiring Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .../workflow-runtime/get-world-handlers.mdx | 13 ++-- .../api-reference/workflow-runtime/index.mdx | 2 +- .../workflow-runtime/workflow-entrypoint.mdx | 2 +- .../v5/deploying/world/postgres-world.mdx | 60 ++++++++++++++++++- .../docs/v5/deploying/world/vercel-world.mdx | 2 +- packages/cli/src/lib/inspect/flags.test.ts | 12 ++++ packages/cli/src/lib/inspect/flags.ts | 3 - packages/cli/src/lib/inspect/setup.ts | 9 +-- packages/next/src/index.test.ts | 40 +++++++++++-- packages/next/src/index.ts | 11 +++- packages/nitro/src/index.test.ts | 19 +++--- packages/nitro/src/index.ts | 34 ++++------- 12 files changed, 155 insertions(+), 52 deletions(-) create mode 100644 packages/cli/src/lib/inspect/flags.test.ts diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx index 4c2bfad0a4..010b6b4d67 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx @@ -1,15 +1,18 @@ --- title: getWorldHandlers -description: Build-time-safe access to the World's queue handlers without binding to runtime environment variables. +description: Resolve the active World's queue handlers. type: reference -summary: Use getWorldHandlers at build time to access queue handler creation without caching an environment-bound World. +summary: Access queue handler creation through the managed runtime World. prerequisites: - /docs/api-reference/workflow-runtime/get-world --- -Returns a restricted view of the [World](/docs/api-reference/workflow-runtime/world) exposing only the members that are safe to use at build time: `createQueueHandler` and `specVersion`. Framework adapters use it while generating workflow route handlers, before the deployment's runtime environment variables exist. - -Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), this function does not cache a fully configured World instance — caching at build time would lock in incomplete environment configuration. +Returns `createQueueHandler` and `specVersion` from the same managed +[World](/docs/api-reference/workflow-runtime/world) as +[`getWorld()`](/docs/api-reference/workflow-runtime/get-world). The first call +resolves and caches the World, starting configured providers before returning. +Generated route handlers call this lazily when they receive their first +request. ```typescript lineNumbers import { getWorldHandlers } from "workflow/runtime"; diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx index 042e181c11..557cf03be4 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx @@ -32,7 +32,7 @@ These functions are primarily used by framework adapters and custom world setups Override the cached World instance with a custom World. - Build-time-safe access to the World's queue handlers. + Resolve queue handlers from the managed World. Create the HTTP route handler that executes workflow runs. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx index efce612990..084136fc3a 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx @@ -38,5 +38,5 @@ Returns a fetch-style request handler: `(req: Request) => Promise`. ## Related Functions -- [`getWorldHandlers()`](/docs/api-reference/workflow-runtime/get-world-handlers) - The build-time World access this handler is built on. +- [`getWorldHandlers()`](/docs/api-reference/workflow-runtime/get-world-handlers) - Resolve queue handlers from the managed World. - [`healthCheck()`](/docs/api-reference/workflow-runtime/health-check) - Verify the entrypoint processes queue messages end-to-end. diff --git a/docs/content/docs/v5/deploying/world/postgres-world.mdx b/docs/content/docs/v5/deploying/world/postgres-world.mdx index 88fe12f702..b1125b0477 100644 --- a/docs/content/docs/v5/deploying/world/postgres-world.mdx +++ b/docs/content/docs/v5/deploying/world/postgres-world.mdx @@ -61,8 +61,64 @@ The migration is idempotent and can safely be run as a post-deployment lifecycle ## Starting the World Workflow calls `start()` once, immediately before the configured World is first -used. If you construct a Postgres World outside the provider, call `start()` -yourself during server initialization. +used. Because the Postgres worker must also recover queued work after a process +restart, resolve it during server startup rather than waiting for the first +request. + + + + + +```typescript title="instrumentation.ts" lineNumbers +export async function register() { + if (process.env.NEXT_RUNTIME !== "edge") { + const { getWorld } = await import("workflow/runtime"); + await getWorld(); + } +} +``` + + + + + +```typescript title="src/hooks.server.ts" lineNumbers +import type { ServerInit } from "@sveltejs/kit"; + +export const init: ServerInit = async () => { + const { getWorld } = await import("workflow/runtime"); + await getWorld(); +}; +``` + + + + + +```typescript title="plugins/start-postgres-world.ts" lineNumbers +import { defineNitroPlugin } from "nitro/~internal/runtime/plugin"; + +export default defineNitroPlugin(async () => { + const { getWorld } = await import("workflow/runtime"); + await getWorld(); +}); +``` + +```typescript title="nitro.config.ts" lineNumbers +import { defineConfig } from "nitro"; + +export default defineConfig({ + modules: ["workflow/nitro"], + plugins: ["plugins/start-postgres-world.ts"], +}); +``` + + + + + +If you construct a Postgres World outside a provider, call `start()` yourself +during server initialization. The Postgres World requires a long-lived worker process that polls the database for jobs. This does not work on serverless environments. For Vercel deployments, use the [Vercel World](/docs/deploying/world/vercel-world) instead. diff --git a/docs/content/docs/v5/deploying/world/vercel-world.mdx b/docs/content/docs/v5/deploying/world/vercel-world.mdx index 46a27ee29f..ac4acdcb2f 100644 --- a/docs/content/docs/v5/deploying/world/vercel-world.mdx +++ b/docs/content/docs/v5/deploying/world/vercel-world.mdx @@ -94,7 +94,7 @@ The Vercel World requires no configuration when deployed to Vercel. For advanced ### `WORKFLOW_VERCEL_BACKEND_URL` -Custom base URL for the Vercel workflow API. Automatically detected. +Overrides the Vercel workflow API proxy URL. ### Programmatic configuration diff --git a/packages/cli/src/lib/inspect/flags.test.ts b/packages/cli/src/lib/inspect/flags.test.ts new file mode 100644 index 0000000000..988ef9129d --- /dev/null +++ b/packages/cli/src/lib/inspect/flags.test.ts @@ -0,0 +1,12 @@ +import { Parser } from '@oclif/core'; +import { expect, it } from 'vitest'; +import { cliFlags } from './flags.js'; + +it('parses without an explicit backend', async () => { + const { flags } = await Parser.parse([], { flags: cliFlags }); + + expect(flags.backend).toBeUndefined(); + expect(flags.authToken).toBeUndefined(); + expect(flags.project).toBeUndefined(); + expect(flags.team).toBeUndefined(); +}); diff --git a/packages/cli/src/lib/inspect/flags.ts b/packages/cli/src/lib/inspect/flags.ts index a95148ce23..466c9e244d 100644 --- a/packages/cli/src/lib/inspect/flags.ts +++ b/packages/cli/src/lib/inspect/flags.ts @@ -42,7 +42,6 @@ export const cliFlags = { required: false, char: 'a', dependsOn: ['backend'], - default: '', env: 'WORKFLOW_VERCEL_AUTH_TOKEN', helpGroup: 'Target', helpLabel: '-a, --authToken', @@ -52,7 +51,6 @@ export const cliFlags = { description: 'If backend is vercel, the vercel project to authenticate against', required: false, - default: '', dependsOn: ['backend'], env: 'WORKFLOW_VERCEL_PROJECT', helpGroup: 'Target', @@ -64,7 +62,6 @@ export const cliFlags = { 'If backend is vercel, the vercel team to authenticate against', required: false, dependsOn: ['backend'], - default: '', env: 'WORKFLOW_VERCEL_TEAM', helpGroup: 'Target', helpLabel: '--team', diff --git a/packages/cli/src/lib/inspect/setup.ts b/packages/cli/src/lib/inspect/setup.ts index 74be39d3d9..3c4ab20c89 100644 --- a/packages/cli/src/lib/inspect/setup.ts +++ b/packages/cli/src/lib/inspect/setup.ts @@ -23,9 +23,9 @@ export const setupCliWorld = async ( verbose: boolean; backend?: string; env?: string; - authToken: string; - project: string; - team: string; + authToken?: string; + project?: string; + team?: string; port?: number; }, version: string, @@ -101,7 +101,8 @@ export const setupCliWorld = async ( // Seed the initial flags into process.env so inferVercelEnvVars() can // read them via getEnvVars() as starting values before inference. writeEnvVars({ - WORKFLOW_VERCEL_ENV: flags.env, + WORKFLOW_VERCEL_ENV: + flags.env ?? process.env.WORKFLOW_VERCEL_ENV ?? 'production', WORKFLOW_VERCEL_AUTH_TOKEN: flags.authToken, WORKFLOW_VERCEL_PROJECT: flags.project, WORKFLOW_VERCEL_TEAM: flags.team, diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 58adba6668..95062fac6a 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -170,6 +170,35 @@ describe('withWorkflow builder config', () => { } }); + it('resolves the runtime binding from Next.js detected root', async () => { + const projectDir = mkdtempSync(join(realTmpDir, 'workflow-next-root-')); + process.chdir(projectDir); + writeFile( + join(projectDir, 'workflow.config.ts'), + `export default { world: './workflow.world.ts' };` + ); + writeFile( + join(projectDir, 'workflow.world.ts'), + 'export default () => {};' + ); + + try { + const config = withWorkflow({}); + const resolvedConfig = await config('phase-production-build', { + defaultConfig: {}, + }); + + expect( + (resolvedConfig.turbopack?.resolveAlias as Record)[ + '@workflow/config/runtime-binding' + ] + ).toBe('./node_modules/.cache/workflow/runtime-config.mjs'); + } finally { + process.chdir(originalCwd); + rmSync(projectDir, { recursive: true, force: true }); + } + }); + it('configures diagnostics inside the default Next.js dist dir', async () => { const config = withWorkflow({}); @@ -315,15 +344,18 @@ describe('withWorkflow builder config', () => { expect(builderConfigs[0]?.externalPackages).toContain( 'configured-external' ); + const runtimeConfigRequest = relative( + '/explicit-root', + join(projectDir, 'node_modules/.cache/workflow/runtime-config.mjs') + ).replaceAll('\\', '/'); expect( (resolvedConfig.turbopack?.resolveAlias as Record)[ '@workflow/config/runtime-binding' ] ).toBe( - `./${relative( - turbopackRoot, - join(projectDir, 'node_modules/.cache/workflow/runtime-config.mjs') - ).replaceAll('\\', '/')}` + runtimeConfigRequest.startsWith('.') + ? runtimeConfigRequest + : `./${runtimeConfigRequest}` ); } finally { process.chdir(originalCwd); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 1a120fcf1f..fc574e394b 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -469,8 +469,17 @@ export function withWorkflow( ) ? nextConfig.turbopack.resolveAlias : {}; + const configuredRoot = + nextConfig.outputFileTracingRoot || nextConfig.turbopack.root; + const turbopackRoot = configuredRoot + ? resolve(configuredRoot) + : ( + require('next/dist/lib/find-root') as { + findRootDirAndLockFiles(cwd: string): { rootDir: string }; + } + ).findRootDirAndLockFiles(process.cwd()).rootDir; const runtimeConfigRequest = relative( - nextConfig.turbopack.root ?? process.cwd(), + turbopackRoot, runtimeConfigPath ).replaceAll('\\', '/'); nextConfig.turbopack.resolveAlias = { diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts index 3465483f94..d8b0811c56 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -61,6 +61,7 @@ function createNitroStub({ dev, externals: externals ?? {}, handlers: [], + plugins: [], preset, rootDir, typescript: {}, @@ -146,7 +147,7 @@ describe('@workflow/nitro virtual handlers', () => { expect(source).not.toContain('@workflow/config'); }); - it('imports runtime config before unbundled dev routes', async () => { + it('installs runtime config as a Nitro plugin', async () => { const project = createProject('export default {};'); const nitro = createNitroStub({ routing: false, @@ -156,11 +157,9 @@ describe('@workflow/nitro virtual handlers', () => { await nitroModule.setup(nitro); - const source = nitro.options.virtual['#workflow/workflows.mjs']; - const binding = 'import "@workflow/config/runtime-binding";'; - expect(source).toContain(binding); - expect(source.indexOf(binding)).toBeLessThan( - source.indexOf('import(currentImportPath)') + expect(nitro.options.plugins[0]).toBe('#workflow/runtime-config'); + expect(nitro.options.virtual['#workflow/runtime-config']).toContain( + 'import "@workflow/config/runtime-binding";' ); }); }); @@ -213,8 +212,7 @@ 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 "@workflow/config/runtime-binding";'); + expect(nitro.options.plugins).toContain('#workflow/runtime-config'); }); it('prefers environment variables over workflow.config.ts', async () => { @@ -383,10 +381,14 @@ describe('@workflow/nitro Vercel functionRules', () => { // routes, so we must NOT touch functionRules — and we must register a // `compiled` hook that runs the VercelBuilder. const compiledHooks: Array<() => void> = []; + const project = createProject( + `export default { queue: { namespace: 'legacy' } };` + ); const nitro = createNitroStub({ routing: false, majorVersion: 2, preset: 'vercel', + rootDir: project, }); nitro.hooks.hook = (name: string, fn: () => void) => { if (name === 'compiled') compiledHooks.push(fn); @@ -396,6 +398,7 @@ describe('@workflow/nitro Vercel functionRules', () => { expect(nitro.options.vercel?.functionRules ?? {}).toEqual({}); expect(compiledHooks.length).toBe(1); + expect(nitro.options.plugins).toContain('#workflow/runtime-config'); }); }); diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index 4713f764bc..ea89b91c3f 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -11,6 +11,8 @@ import type { ModuleOptions } from './types'; export type { ModuleOptions }; +const RUNTIME_CONFIG_PLUGIN_ID = '#workflow/runtime-config'; + /** * Detect whether the Nitro instance is v2. * Newer Nitro releases (both v2 and v3) expose `nitro.meta.majorVersion`. @@ -56,6 +58,13 @@ export const nitroModule = { namespace: process.env.WORKFLOW_QUEUE_NAMESPACE ?? workflowConfig.queue?.namespace, }); + if (runtimeConfigPath) { + nitro.options.virtual[RUNTIME_CONFIG_PLUGIN_ID] = ` + import "@workflow/config/runtime-binding"; + export default () => {}; + `; + nitro.options.plugins.unshift(RUNTIME_CONFIG_PLUGIN_ID); + } const isVercelDeploy = !nitro.options.dev && nitro.options.preset === 'vercel'; @@ -256,8 +265,7 @@ export const nitroModule = { addVirtualHandler( nitro, '/.well-known/workflow/v1/webhook/:token', - 'workflow/webhook.mjs', - runtimeConfigPath !== undefined + 'workflow/webhook.mjs' ); // V2: single combined handler for both workflow and step execution. @@ -266,8 +274,7 @@ export const nitroModule = { addVirtualHandler( nitro, '/.well-known/workflow/v1/flow', - 'workflow/workflows.mjs', - runtimeConfigPath !== undefined + 'workflow/workflows.mjs' ); // Nitro v3+ Vercel deploy: configure function rules for the combined @@ -410,12 +417,7 @@ function addDashboardHandler(nitro: Nitro) { } } -function addVirtualHandler( - nitro: Nitro, - route: string, - buildPath: string, - hasRuntimeConfig: boolean -) { +function addVirtualHandler(nitro: Nitro, route: string, buildPath: string) { nitro.options.handlers.push({ route, handler: `#${buildPath}`, @@ -423,12 +425,6 @@ function addVirtualHandler( const handlerImportPath = JSON.stringify( join(nitro.options.buildDir, buildPath) ); - const runtimeConfigSetup = hasRuntimeConfig - ? ` - import "@workflow/config/runtime-binding"; - ` - : ''; - 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, @@ -439,8 +435,6 @@ function addVirtualHandler( import { fromWebHandler } from "h3"; import { statSync } from "node:fs"; import { pathToFileURL } from "node:url"; - ${runtimeConfigSetup} - const handlerPath = ${handlerImportPath}; let currentVersion = ""; let currentImportPath = ""; @@ -463,8 +457,6 @@ function addVirtualHandler( nitro.options.virtual[`#${buildPath}`] = /* js */ ` import { statSync } from "node:fs"; import { pathToFileURL } from "node:url"; - ${runtimeConfigSetup} - const handlerPath = ${handlerImportPath}; let currentVersion = ""; let currentImportPath = ""; @@ -500,7 +492,6 @@ function addVirtualHandler( if (!nitro.routing) { // Nitro v2 (legacy) nitro.options.virtual[`#${buildPath}`] = /* js */ ` - ${runtimeConfigSetup} import ${handlerImportPath}; import { fromWebHandler } from "h3"; import { POST } from ${handlerImportPath}; @@ -509,7 +500,6 @@ 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 ed092da30bed7e183ab921eae6ee78d11116d112 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:24:30 -0700 Subject: [PATCH 19/28] Wire shared config into Vite frameworks Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .changeset/framework-shared-config.md | 4 +- .../workflow-runtime/create-world.mdx | 11 ++++-- .../api-reference/workflow-runtime/index.mdx | 2 +- .../workflow-runtime/set-world.mdx | 11 ++++-- packages/astro/package.json | 1 + packages/astro/src/builder.ts | 38 +++++++++++++------ packages/astro/src/plugin.ts | 24 +++++++++++- packages/next/src/index.test.ts | 2 +- packages/next/src/index.ts | 22 ++++++----- packages/sveltekit/package.json | 1 + packages/sveltekit/src/builder.ts | 20 +++++++--- packages/sveltekit/src/index.ts | 18 ++++----- packages/sveltekit/src/plugin.ts | 24 +++++++++++- packages/sveltekit/src/vc-config.test.ts | 10 +++++ packages/sveltekit/src/vc-config.ts | 8 ++-- pnpm-lock.yaml | 6 +++ 16 files changed, 148 insertions(+), 54 deletions(-) diff --git a/.changeset/framework-shared-config.md b/.changeset/framework-shared-config.md index 495a36ffae..2cfc706ea3 100644 --- a/.changeset/framework-shared-config.md +++ b/.changeset/framework-shared-config.md @@ -1,6 +1,8 @@ --- "@workflow/next": minor "@workflow/nitro": minor +"@workflow/sveltekit": minor +"@workflow/astro": minor --- -Add typed shared configuration support to the Next.js and Nitro integrations. +Add typed shared configuration support to framework integrations. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx index afbe0339fb..a67cbf33bd 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx @@ -1,15 +1,18 @@ --- title: createWorld -description: Create a new World instance from environment configuration. +description: Resolve a new World instance from the active Workflow configuration. type: reference -summary: Use createWorld to instantiate a World from WORKFLOW_TARGET_WORLD environment configuration, bypassing the cached instance. +summary: Resolve a fresh World without using the managed runtime cache. prerequisites: - /docs/api-reference/workflow-runtime/get-world related: - /docs/api-reference/workflow-runtime/set-world --- -Creates a new [World](/docs/api-reference/workflow-runtime/world) instance based on environment configuration. The `WORKFLOW_TARGET_WORLD` environment variable determines which World implementation is instantiated (for example the local development World or the Vercel production World). +Creates a new [World](/docs/api-reference/workflow-runtime/world) using the +same selection order as [`getWorld()`](/docs/api-reference/workflow-runtime/get-world): +`WORKFLOW_TARGET_WORLD`, the provider in `workflow.config.ts`, then the +automatic Vercel or Local World. Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), which caches a singleton instance, `createWorld()` constructs a fresh instance on every call. Application code should almost always use `getWorld()` — `createWorld()` is for infrastructure code that manages World lifecycles itself. @@ -23,7 +26,7 @@ const world = await createWorld(); // [!code highlight] ### Parameters -This function does not accept any parameters. Configuration is read from environment variables. +This function does not accept any parameters. ### Returns diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx index 557cf03be4..37d15c6954 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx @@ -26,7 +26,7 @@ These functions are primarily used by framework adapters and custom world setups - Create a World instance from environment configuration. + Resolve a fresh World without using the managed cache. Override the cached World instance with a custom World. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx index 6dcc03d8bd..d7779f7fe9 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx @@ -2,14 +2,17 @@ title: setWorld description: Override or reset the cached World instance used by the workflow runtime. type: reference -summary: Use setWorld to inject a custom World instance or reset the cache after environment configuration changes. +summary: Use setWorld to inject a custom World instance or reset the managed cache. prerequisites: - /docs/api-reference/workflow-runtime/get-world related: - /docs/api-reference/workflow-runtime/create-world --- -Overrides the cached [World](/docs/api-reference/workflow-runtime/world) instance that [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) returns. Use it to inject a World constructed with explicit configuration (rather than environment variables), or pass `undefined` to clear the cache so the next `getWorld()` call reinitializes from the current environment. +Overrides the cached [World](/docs/api-reference/workflow-runtime/world) +instance that [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) +returns. Pass `undefined` to clear the cache so the next call resolves the +active target override, configured provider, or automatic World again. ```typescript lineNumbers import { setWorld, getWorld } from "workflow/runtime"; @@ -26,7 +29,7 @@ const world = await getWorld(); // resolves customWorld | Parameter | Type | Description | |-----------|------|-------------| -| `world` | `World \| undefined` | The World instance to use, or `undefined` to reset the cache and reinitialize from environment variables on next access | +| `world` | `World \| undefined` | The World instance to use, or `undefined` to resolve the active configuration again on next access | ### Returns @@ -46,4 +49,4 @@ const world = await getWorld(); // reinitialized with new configuration ## Related Functions - [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the cached World instance. -- [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) - Construct a fresh World from environment configuration. +- [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) - Resolve a fresh World without using the cache. diff --git a/packages/astro/package.json b/packages/astro/package.json index 25c1c4be3c..b069936ccf 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -27,6 +27,7 @@ "dependencies": { "@swc/core": "1.15.3", "@workflow/builders": "workspace:*", + "@workflow/config": "workspace:*", "@workflow/rollup": "workspace:*", "@workflow/swc-plugin": "workspace:*", "@workflow/vite": "workspace:*", diff --git a/packages/astro/src/builder.ts b/packages/astro/src/builder.ts index 4987acbe72..c3c5d57fd9 100644 --- a/packages/astro/src/builder.ts +++ b/packages/astro/src/builder.ts @@ -20,18 +20,26 @@ const WORKFLOW_ROUTES = [ ]; export class LocalBuilder extends BaseBuilder { - constructor(options?: { - sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both'; - }) { + constructor(config: Partial = {}) { + const workingDir = config.workingDir ?? process.cwd(); + const build = config.workflowConfig?.config.build; + super({ - dirs: ['src/pages', 'src/workflows'], + ...config, + dirs: config.dirs ?? build?.dirs ?? ['src/pages', 'src/workflows'], buildTarget: 'astro' as const, stepsBundlePath: '', // unused in base workflowsBundlePath: '', // unused in base webhookBundlePath: '', // unused in base - workingDir: process.cwd(), + workingDir, + projectRoot: + config.projectRoot ?? + (build?.projectRoot + ? resolve(workingDir, build.projectRoot) + : undefined), + externalPackages: config.externalPackages ?? build?.externalPackages, debugFilePrefix: '_', // Prefix with underscore so Astro ignores debug files - sourcemap: options?.sourcemap, + sourcemap: config.sourcemap, }); } @@ -162,14 +170,22 @@ export const prerender = false;` } export class VercelBuilder extends VercelBuildOutputAPIBuilder { - constructor(config?: Partial) { - const workingDir = config?.workingDir || process.cwd(); + constructor(config: Partial = {}) { + const workingDir = config.workingDir ?? process.cwd(); + const build = config.workflowConfig?.config.build; super({ ...createBaseBuilderConfig({ workingDir, - dirs: ['src/pages', 'src/workflows'], - runtime: config?.runtime, - sourcemap: config?.sourcemap, + dirs: config.dirs ?? build?.dirs ?? ['src/pages', 'src/workflows'], + projectRoot: + config.projectRoot ?? + (build?.projectRoot + ? resolve(workingDir, build.projectRoot) + : undefined), + externalPackages: config.externalPackages ?? build?.externalPackages, + runtime: config.runtime, + sourcemap: config.sourcemap, + workflowConfig: config.workflowConfig, }), buildTarget: 'vercel-build-output-api', debugFilePrefix: '_', diff --git a/packages/astro/src/plugin.ts b/packages/astro/src/plugin.ts index 968f5b26c4..6dc8c44e0b 100644 --- a/packages/astro/src/plugin.ts +++ b/packages/astro/src/plugin.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 { AstroIntegration, HookParameters } from 'astro'; @@ -17,7 +18,8 @@ export interface WorkflowPluginOptions { export function workflowPlugin( options: WorkflowPluginOptions = {} ): AstroIntegration { - const builder = new LocalBuilder({ sourcemap: options.sourcemap }); + const workflowConfig = loadWorkflowConfig({ cwd: process.cwd() }); + let builder: LocalBuilder; const enqueue = createBuildQueue(); return { @@ -26,6 +28,11 @@ export function workflowPlugin( 'astro:config:setup': async ({ updateConfig, }: HookParameters<'astro:config:setup'>) => { + const loadedConfig = await workflowConfig; + builder = new LocalBuilder({ + sourcemap: options.sourcemap, + workflowConfig: loadedConfig, + }); // Use local builder if (!process.env.VERCEL_DEPLOYMENT_ID) { try { @@ -39,11 +46,23 @@ export function workflowPlugin( } updateConfig({ vite: { + ...(loadedConfig.runtimePath + ? { ssr: { noExternal: ['workflow', '@workflow/core'] } } + : {}), plugins: [ workflowTransformPlugin(), + { + name: 'workflow:runtime-config', + enforce: 'pre', + resolveId(source) { + if (source === '@workflow/config/runtime-binding') { + return loadedConfig.runtimePath; + } + }, + }, // Cast needed due to Astro using a different internal Vite version workflowHotUpdatePlugin({ - builder, + builder: () => builder, enqueue, }) as any, ], @@ -54,6 +73,7 @@ export function workflowPlugin( if (process.env.VERCEL_DEPLOYMENT_ID) { const vercelBuilder = new VercelBuilder({ sourcemap: options.sourcemap, + workflowConfig: await workflowConfig, }); await vercelBuilder.build(); } diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 95062fac6a..8a2de1c860 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -345,7 +345,7 @@ describe('withWorkflow builder config', () => { 'configured-external' ); const runtimeConfigRequest = relative( - '/explicit-root', + turbopackRoot, join(projectDir, 'node_modules/.cache/workflow/runtime-config.mjs') ).replaceAll('\\', '/'); expect( diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index fc574e394b..fb9edc2c61 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -463,21 +463,24 @@ export function withWorkflow( if (!nextConfig.turbopack.rules) { nextConfig.turbopack.rules = {}; } + const nextVersion = resolveNextVersion(process.cwd()); if (runtimeConfigPath) { const existingResolveAlias = isPlainObject( nextConfig.turbopack.resolveAlias ) ? nextConfig.turbopack.resolveAlias : {}; - const configuredRoot = - nextConfig.outputFileTracingRoot || nextConfig.turbopack.root; - const turbopackRoot = configuredRoot - ? resolve(configuredRoot) - : ( - require('next/dist/lib/find-root') as { - findRootDirAndLockFiles(cwd: string): { rootDir: string }; - } - ).findRootDirAndLockFiles(process.cwd()).rootDir; + const turbopackRoot = resolve( + nextConfig.turbopack.root ?? + nextConfig.outputFileTracingRoot ?? + (semver.gte(nextVersion, '16.0.0') + ? ( + require('next/dist/lib/find-root') as { + findRootDirAndLockFiles(cwd: string): { rootDir: string }; + } + ).findRootDirAndLockFiles(process.cwd()).rootDir + : process.cwd()) + ); const runtimeConfigRequest = relative( turbopackRoot, runtimeConfigPath @@ -490,7 +493,6 @@ export function withWorkflow( }; } const existingRules = nextConfig.turbopack.rules as any; - const nextVersion = resolveNextVersion(process.cwd()); const supportsTurboCondition = semver.gte(nextVersion, 'v16.0.0'); const shouldWatch = process.env.NODE_ENV === 'development'; diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 134e92fda7..fb0b70ccfa 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -27,6 +27,7 @@ "dependencies": { "@swc/core": "catalog:", "@workflow/builders": "workspace:*", + "@workflow/config": "workspace:*", "@workflow/rollup": "workspace:*", "@workflow/swc-plugin": "workspace:*", "@workflow/vite": "workspace:*", diff --git a/packages/sveltekit/src/builder.ts b/packages/sveltekit/src/builder.ts index 3ed5011993..7b5d556479 100644 --- a/packages/sveltekit/src/builder.ts +++ b/packages/sveltekit/src/builder.ts @@ -23,19 +23,29 @@ const SVELTEKIT_VIRTUAL_MODULES = [ ]; export class SvelteKitBuilder extends BaseBuilder { - constructor(config?: Partial) { - const workingDir = config?.workingDir || process.cwd(); + constructor(config: Partial = {}) { + const workingDir = config.workingDir ?? process.cwd(); + const build = config.workflowConfig?.config.build; super({ ...config, - dirs: ['workflows', 'src/workflows', 'routes', 'src/routes'], + dirs: config.dirs ?? + build?.dirs ?? ['workflows', 'src/workflows', 'routes', 'src/routes'], buildTarget: 'sveltekit' as const, stepsBundlePath: '', // unused in base workflowsBundlePath: '', // unused in base webhookBundlePath: '', // unused in base workingDir, - externalPackages: [...SVELTEKIT_VIRTUAL_MODULES], - sourcemap: config?.sourcemap, + projectRoot: + config.projectRoot ?? + (build?.projectRoot + ? resolve(workingDir, build.projectRoot) + : undefined), + externalPackages: [ + ...SVELTEKIT_VIRTUAL_MODULES, + ...(config.externalPackages ?? build?.externalPackages ?? []), + ], + sourcemap: config.sourcemap, }); } diff --git a/packages/sveltekit/src/index.ts b/packages/sveltekit/src/index.ts index d3f4a18a64..4cb2c193ca 100644 --- a/packages/sveltekit/src/index.ts +++ b/packages/sveltekit/src/index.ts @@ -1,22 +1,20 @@ import path from 'node:path'; -import { WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders'; +import { createWorkflowQueueTrigger } from '@workflow/builders'; import fs from 'fs-extra'; -import { SvelteKitBuilder } from './builder.js'; +import { loadedWorkflowConfig } from './plugin.js'; import { stripWorkflowQueueTriggers } from './vc-config.js'; -const builder = new SvelteKitBuilder(); - -// This needs to be in the top-level as we need to create these -// entries before svelte plugin is started or the entries are -// a race to be created before svelte discovers entries -await builder.build(); - process.on('beforeExit', () => { // Don't patch functions output if not in Vercel adapter if (!process.env.VERCEL_DEPLOYMENT_ID) { return; } + const workflowQueueTrigger = createWorkflowQueueTrigger({ + namespace: + process.env.WORKFLOW_QUEUE_NAMESPACE ?? + loadedWorkflowConfig.config.queue?.namespace, + }); // V2: Only the combined flow handler needs queue triggers. // The separate step route was removed. for (const { file, config } of [ @@ -24,7 +22,7 @@ process.on('beforeExit', () => { file: '.vercel/output/functions/.well-known/workflow/v1/flow.func/.vc-config.json', config: { maxDuration: 'max', - experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER], + experimentalTriggers: [workflowQueueTrigger], }, }, ]) { diff --git a/packages/sveltekit/src/plugin.ts b/packages/sveltekit/src/plugin.ts index 28193621a0..11b47ca32f 100644 --- a/packages/sveltekit/src/plugin.ts +++ b/packages/sveltekit/src/plugin.ts @@ -1,9 +1,14 @@ import { createBuildQueue } from '@workflow/builders'; +import { loadWorkflowConfig } from '@workflow/config/load'; import { workflowTransformPlugin } from '@workflow/rollup'; import { workflowHotUpdatePlugin } from '@workflow/vite'; import type { Plugin } from 'vite'; import { SvelteKitBuilder } from './builder.js'; +export const loadedWorkflowConfig = await loadWorkflowConfig({ + cwd: process.cwd(), +}); + export interface WorkflowPluginOptions { /** * Controls how source maps are emitted for workflow bundles. Accepts the @@ -15,13 +20,30 @@ export interface WorkflowPluginOptions { } export function workflowPlugin(options: WorkflowPluginOptions = {}): Plugin[] { - const builder = new SvelteKitBuilder({ sourcemap: options.sourcemap }); + const builder = new SvelteKitBuilder({ + sourcemap: options.sourcemap, + workflowConfig: loadedWorkflowConfig, + }); const enqueue = createBuildQueue(); + const initialBuild = builder.build(); return [ workflowTransformPlugin() as Plugin, { name: 'workflow:sveltekit', + enforce: 'pre', + async config() { + await initialBuild; + if (!loadedWorkflowConfig.runtimePath) return; + return { + ssr: { noExternal: ['workflow', '@workflow/core'] }, + }; + }, + resolveId(source) { + if (source === '@workflow/config/runtime-binding') { + return loadedWorkflowConfig.runtimePath; + } + }, }, workflowHotUpdatePlugin({ builder, diff --git a/packages/sveltekit/src/vc-config.test.ts b/packages/sveltekit/src/vc-config.test.ts index ef8df10704..52401db697 100644 --- a/packages/sveltekit/src/vc-config.test.ts +++ b/packages/sveltekit/src/vc-config.test.ts @@ -33,6 +33,16 @@ describe('stripWorkflowQueueTriggersFromConfig', () => { }); }); + it('removes namespaced workflow queue triggers', () => { + expect( + stripWorkflowQueueTriggersFromConfig({ + experimentalTriggers: [ + { ...WORKFLOW_QUEUE_TRIGGER, topic: '__myapp_wkf_workflow_*' }, + ], + }) + ).toEqual({}); + }); + it('leaves configs without workflow triggers unchanged', () => { const config = { runtime: 'nodejs', diff --git a/packages/sveltekit/src/vc-config.ts b/packages/sveltekit/src/vc-config.ts index 3c9fc50e42..13a2201039 100644 --- a/packages/sveltekit/src/vc-config.ts +++ b/packages/sveltekit/src/vc-config.ts @@ -1,15 +1,15 @@ -import { WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders'; import fs from 'fs-extra'; -const WORKFLOW_QUEUE_TOPICS = new Set([WORKFLOW_QUEUE_TRIGGER.topic]); - function isWorkflowQueueTrigger(trigger: unknown) { if (typeof trigger !== 'object' || trigger === null) { return false; } const topic = (trigger as { topic?: unknown }).topic; - return typeof topic === 'string' && WORKFLOW_QUEUE_TOPICS.has(topic); + return ( + typeof topic === 'string' && + /^__(?:[a-z][a-z0-9]*_)?wkf_workflow_\*$/.test(topic) + ); } export function stripWorkflowQueueTriggersFromConfig< diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9522725bf3..35155979d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -375,6 +375,9 @@ importers: '@workflow/builders': specifier: workspace:* version: link:../builders + '@workflow/config': + specifier: workspace:* + version: link:../config '@workflow/rollup': specifier: workspace:* version: link:../rollup @@ -945,6 +948,9 @@ importers: '@workflow/builders': specifier: workspace:* version: link:../builders + '@workflow/config': + specifier: workspace:* + version: link:../config '@workflow/rollup': specifier: workspace:* version: link:../rollup From 7d0f68c3474abccf6ab619d4a126587225fa78f8 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:41:14 -0700 Subject: [PATCH 20/28] Unify runtime namespace and World lifecycle Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .../workflow-runtime/close-world.mdx | 34 +++++++++++++++++++ .../api-reference/workflow-runtime/index.mdx | 3 ++ .../api-reference/workflow-runtime/meta.json | 1 + .../workflow-runtime/set-world.mdx | 19 ++++++----- .../v5/deploying/world/postgres-world.mdx | 5 +-- packages/builders/src/base-builder.ts | 1 - packages/builders/src/index.ts | 1 - packages/config/src/load.test.ts | 15 ++++++++ packages/config/src/load.ts | 5 ++- .../core/src/runtime/world-config.test.ts | 22 +++++++++++- packages/core/src/runtime/world.ts | 29 +++++++++++++--- packages/workflow/src/runtime.ts | 1 + 12 files changed, 116 insertions(+), 20 deletions(-) create mode 100644 docs/content/docs/v5/api-reference/workflow-runtime/close-world.mdx diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/close-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/close-world.mdx new file mode 100644 index 0000000000..df34364d8e --- /dev/null +++ b/docs/content/docs/v5/api-reference/workflow-runtime/close-world.mdx @@ -0,0 +1,34 @@ +--- +title: closeWorld +description: Close and clear the World managed by the workflow runtime. +type: reference +summary: Release the active World's resources and reset the runtime cache. +prerequisites: + - /docs/api-reference/workflow-runtime/get-world +related: + - /docs/api-reference/workflow-runtime/set-world +--- + +Closes the cached [World](/docs/api-reference/workflow-runtime/world) and clears +it from the runtime. If a World is still starting, `closeWorld()` waits for +startup before closing it. It does not create a World when the cache is empty. + +```typescript lineNumbers +import { closeWorld } from "workflow/runtime"; + +await closeWorld(); // [!code highlight] +``` + +The next [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) call +resolves the active target override, configured provider, or automatic World +again. + +## API Signature + +### Parameters + +This function does not accept any parameters. + +### Returns + +Returns a `Promise`. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx index 37d15c6954..d6ece115fc 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx @@ -31,6 +31,9 @@ These functions are primarily used by framework adapters and custom world setups Override the cached World instance with a custom World. + + Close and clear the managed World. + Resolve queue handlers from the managed World. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/meta.json b/docs/content/docs/v5/api-reference/workflow-runtime/meta.json index c8081938a0..97b9205b96 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/meta.json +++ b/docs/content/docs/v5/api-reference/workflow-runtime/meta.json @@ -5,6 +5,7 @@ "world", "create-world", "set-world", + "close-world", "get-world-handlers", "workflow-entrypoint", "health-check" diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx index d7779f7fe9..a3e9420203 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx @@ -1,18 +1,20 @@ --- title: setWorld -description: Override or reset the cached World instance used by the workflow runtime. +description: Override the cached World instance used by the workflow runtime. type: reference -summary: Use setWorld to inject a custom World instance or reset the managed cache. +summary: Inject a custom World instance into the runtime cache. prerequisites: - /docs/api-reference/workflow-runtime/get-world related: - /docs/api-reference/workflow-runtime/create-world + - /docs/api-reference/workflow-runtime/close-world --- Overrides the cached [World](/docs/api-reference/workflow-runtime/world) instance that [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) -returns. Pass `undefined` to clear the cache so the next call resolves the -active target override, configured provider, or automatic World again. +returns. Passing `undefined` can clear a manually injected World. Use +[`closeWorld()`](/docs/api-reference/workflow-runtime/close-world) to close and +reset a World started from `workflow.config.ts`. ```typescript lineNumbers import { setWorld, getWorld } from "workflow/runtime"; @@ -29,19 +31,19 @@ const world = await getWorld(); // resolves customWorld | Parameter | Type | Description | |-----------|------|-------------| -| `world` | `World \| undefined` | The World instance to use, or `undefined` to resolve the active configuration again on next access | +| `world` | `World \| undefined` | The World instance to use, or `undefined` to clear a manually injected instance | ### Returns This function does not return a value. -## Example: Reset After Environment Changes +## Resetting the Managed World ```typescript lineNumbers -import { setWorld, getWorld } from "workflow/runtime"; +import { closeWorld, getWorld } from "workflow/runtime"; process.env.WORKFLOW_TARGET_WORLD = "@workflow/world-local"; -setWorld(undefined); // clear the cached instance // [!code highlight] +await closeWorld(); // close and clear the cached instance // [!code highlight] const world = await getWorld(); // reinitialized with new configuration ``` @@ -50,3 +52,4 @@ const world = await getWorld(); // reinitialized with new configuration - [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the cached World instance. - [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) - Resolve a fresh World without using the cache. +- [`closeWorld()`](/docs/api-reference/workflow-runtime/close-world) - Close and clear the managed World. diff --git a/docs/content/docs/v5/deploying/world/postgres-world.mdx b/docs/content/docs/v5/deploying/world/postgres-world.mdx index b1125b0477..5300dcba93 100644 --- a/docs/content/docs/v5/deploying/world/postgres-world.mdx +++ b/docs/content/docs/v5/deploying/world/postgres-world.mdx @@ -28,10 +28,7 @@ Create a provider for the World: import type { WorldProvider } from "workflow/config"; import { createWorld } from "@workflow/world-postgres"; -const world: WorldProvider = () => - createWorld({ - connectionString: process.env.WORKFLOW_POSTGRES_URL!, - }); +const world: WorldProvider = () => createWorld(); export default world; ``` diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index e7deb4ff72..0285571aa1 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -22,7 +22,6 @@ import { applySwcTransform, type WorkflowManifest, } from './apply-swc-transform.js'; -import { createWorkflowEntrypointOptionsCode } from './constants.js'; import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js'; import { type DiscoveredEntries, diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index a1ec9443ea..258f67b81a 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -10,7 +10,6 @@ export { getDecoratorOptionsForDirectoryWithConfigPath, } from './config-helpers.js'; export { - createWorkflowEntrypointOptionsCode, createWorkflowQueueTrigger, WORKFLOW_QUEUE_TRIGGER, } from './constants.js'; diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index ff824abd22..7f67e4688a 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -34,6 +34,7 @@ function createProject(files: Record): string { afterEach(() => { setRuntimeWorkflowConfig(undefined); + delete process.env.WORKFLOW_QUEUE_NAMESPACE; delete (globalThis as { __workflowWorldImports?: number }) .__workflowWorldImports; for (const dir of tempDirs.splice(0)) { @@ -68,6 +69,20 @@ describe('loadWorkflowConfig', () => { expect(getRuntimeWorkflowConfig()).toBe(runtime.default); }); + it('uses the build environment namespace as the runtime fallback', async () => { + process.env.WORKFLOW_QUEUE_NAMESPACE = 'deployment'; + const project = createProject({ + 'workflow.config.ts': `export default { queue: { namespace: 'app' } };`, + }); + const loaded = await loadWorkflowConfig({ cwd: project }); + const runtime = (await import( + pathToFileURL(loaded.runtimePath as string).href + )) as { default: RuntimeWorkflowConfig }; + + expect(loaded.config.queue).toEqual({ namespace: 'app' }); + expect(runtime.default.queue).toEqual({ namespace: 'deployment' }); + }); + it('loads the nearest TypeScript config without merging parents', async () => { const project = createProject({ 'workflow.config.ts': `export default { build: { dirs: ['parent'] } };`, diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 1c92917ed9..ad730515c7 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -135,10 +135,13 @@ export async function loadWorkflowConfig( const worldFactory = world ? `async () => { const provider = (await import(${JSON.stringify(world)})).default; return provider(); }` : 'undefined'; + const namespace = + process.env.WORKFLOW_QUEUE_NAMESPACE ?? config.queue?.namespace; + const queue = namespace === undefined ? undefined : { namespace }; mkdirSync(runtimeDir, { recursive: true }); writeFileSync( runtimePath, - `const world = ${worldFactory};\nconst config = { world, queue: ${JSON.stringify(config.queue)} };\nglobalThis[Symbol.for('@workflow/config/runtime')] = config;\nglobalThis[Symbol.for('@workflow/queue/namespace')] = config.queue?.namespace;\nexport default config;\n` + `const world = ${worldFactory};\nconst config = { world, queue: ${JSON.stringify(queue)} };\nglobalThis[Symbol.for('@workflow/config/runtime')] = config;\nglobalThis[Symbol.for('@workflow/queue/namespace')] = config.queue?.namespace;\nexport default config;\n` ); return { path, runtimePath, config }; diff --git a/packages/core/src/runtime/world-config.test.ts b/packages/core/src/runtime/world-config.test.ts index 32174ccd19..24f9eb1ee6 100644 --- a/packages/core/src/runtime/world-config.test.ts +++ b/packages/core/src/runtime/world-config.test.ts @@ -3,7 +3,7 @@ import type { World } from '@workflow/world'; 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'; +import { closeWorld, getWorld, getWorldHandlers, setWorld } from './world.js'; const targetWorld = process.env.WORKFLOW_TARGET_WORLD; @@ -83,6 +83,26 @@ describe('configured World', () => { ); }); + it('requires managed Worlds to be closed before reset', async () => { + delete process.env.WORKFLOW_TARGET_WORLD; + const close = vi.fn(async () => {}); + setRuntimeWorkflowConfig({ + world: () => + ({ + start: vi.fn(async () => {}), + close, + }) as unknown as World, + }); + + await getWorld(); + expect(() => setWorld(undefined)).toThrow( + 'Call await closeWorld() before replacing a managed World.' + ); + await closeWorld(); + expect(close).toHaveBeenCalledOnce(); + expect(() => setWorld(undefined)).not.toThrow(); + }); + it('does not cache a World closed during startup', async () => { delete process.env.WORKFLOW_TARGET_WORLD; let finishStart!: () => void; diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index 7bb4adc5c2..446413990b 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -26,10 +26,16 @@ function getRuntimeRequire() { const WorldCache = Symbol.for('@workflow/world//cache'); const WorldCachePromise = Symbol.for('@workflow/world//cachePromise'); +const ManagedWorldCache = Symbol.for('@workflow/world//managedCache'); +const ManagedWorldCachePromise = Symbol.for( + '@workflow/world//managedCachePromise' +); const globalSymbols: typeof globalThis & { [WorldCache]?: World; [WorldCachePromise]?: Promise; + [ManagedWorldCache]?: boolean; + [ManagedWorldCachePromise]?: boolean; } = globalThis; function getWorkflowConfig() { @@ -197,6 +203,8 @@ export const getWorld = async (): Promise => { let pendingWorld = globalSymbols[WorldCachePromise]; if (!pendingWorld) { + globalSymbols[ManagedWorldCachePromise] = + !process.env.WORKFLOW_TARGET_WORLD && !!getWorkflowConfig().world; pendingWorld = resolveWorld().then(async (resolved) => { switch (resolved.type) { case 'configured': @@ -216,28 +224,39 @@ export const getWorld = async (): Promise => { }); globalSymbols[WorldCachePromise] = pendingWorld; } + const pendingWorldIsManaged = + globalSymbols[ManagedWorldCachePromise] ?? false; try { const world = await pendingWorld; if (globalSymbols[WorldCachePromise] === pendingWorld) { globalSymbols[WorldCache] = world; + globalSymbols[ManagedWorldCache] = pendingWorldIsManaged; + globalSymbols[WorldCachePromise] = undefined; + globalSymbols[ManagedWorldCachePromise] = undefined; } return world; } catch (error) { if (globalSymbols[WorldCachePromise] === pendingWorld) { globalSymbols[WorldCachePromise] = undefined; + globalSymbols[ManagedWorldCachePromise] = undefined; } throw error; } }; -/** - * Reset the cached world instance. This should be called when environment - * variables change and you need to reinitialize the world with new config. - */ +/** Override or clear an unmanaged cached World. */ export const setWorld = (world: World | undefined): void => { + assert( + !globalSymbols[ManagedWorldCache] && + !globalSymbols[ManagedWorldCachePromise], + 'Call await closeWorld() before replacing a managed World.' + ); + globalSymbols[WorldCache] = world; globalSymbols[WorldCachePromise] = undefined; + globalSymbols[ManagedWorldCache] = undefined; + globalSymbols[ManagedWorldCachePromise] = undefined; }; /** @@ -249,6 +268,8 @@ export const closeWorld = async (): Promise => { globalSymbols[WorldCache] = undefined; globalSymbols[WorldCachePromise] = undefined; + globalSymbols[ManagedWorldCache] = undefined; + globalSymbols[ManagedWorldCachePromise] = undefined; const world = cachedWorld ?? (pendingWorld ? await pendingWorld : undefined); await world?.close?.(); diff --git a/packages/workflow/src/runtime.ts b/packages/workflow/src/runtime.ts index 9d8fcb20fa..5decdd4348 100644 --- a/packages/workflow/src/runtime.ts +++ b/packages/workflow/src/runtime.ts @@ -1,4 +1,5 @@ export { + closeWorld, createWorld, getWorld, getWorldHandlers, From 8b9e8e32cb9282e76eabd8b30495b6cd777fa346 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:24:52 -0700 Subject: [PATCH 21/28] Fix CLI World lifecycle and provider validation Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/builders/src/index.ts | 1 + packages/cli/src/lib/inspect/setup.test.ts | 17 +++++++++++++---- packages/cli/src/lib/inspect/setup.ts | 14 +++++--------- packages/config/src/load.test.ts | 15 +++++++++++++++ packages/config/src/load.ts | 6 +++++- 5 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 258f67b81a..a1ec9443ea 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -10,6 +10,7 @@ export { getDecoratorOptionsForDirectoryWithConfigPath, } from './config-helpers.js'; export { + createWorkflowEntrypointOptionsCode, createWorkflowQueueTrigger, WORKFLOW_QUEUE_TRIGGER, } from './constants.js'; diff --git a/packages/cli/src/lib/inspect/setup.test.ts b/packages/cli/src/lib/inspect/setup.test.ts index a91614aee6..01c0489134 100644 --- a/packages/cli/src/lib/inspect/setup.test.ts +++ b/packages/cli/src/lib/inspect/setup.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { setRuntimeWorkflowConfig } from '@workflow/config/runtime'; -import { setWorld } from '@workflow/core/runtime'; +import { closeWorld } from '@workflow/core/runtime'; import type { World } from '@workflow/world'; import { resolveQueueNamespace } from '@workflow/world/queue.js'; import { afterEach, expect, it, vi } from 'vitest'; @@ -16,9 +16,11 @@ const project = mkdtempSync(join(tmpdir(), 'workflow-cli-world-')); const originalCwd = process.env.WORKFLOW_OBSERVABILITY_CWD; const originalTarget = process.env.WORKFLOW_TARGET_WORLD; -afterEach(() => { - setWorld(undefined); +afterEach(async () => { + await closeWorld(); setRuntimeWorkflowConfig(undefined); + delete (globalThis as { __workflowCliWorldStarted?: boolean }) + .__workflowCliWorldStarted; if (originalCwd === undefined) { delete process.env.WORKFLOW_OBSERVABILITY_CWD; } else { @@ -42,7 +44,10 @@ it('uses the configured World instead of the implicit local default', async () = ); writeFileSync( join(project, 'workflow.world.mjs'), - `export default () => ({ source: 'configured' });` + `export default () => ({ + source: 'configured', + start() { globalThis.__workflowCliWorldStarted = true; } + });` ); process.env.WORKFLOW_OBSERVABILITY_CWD = project; delete process.env.WORKFLOW_TARGET_WORLD; @@ -60,6 +65,10 @@ it('uses the configured World instead of the implicit local default', async () = ); expect((world as World & { source: string }).source).toBe('configured'); + expect( + (globalThis as { __workflowCliWorldStarted?: boolean }) + .__workflowCliWorldStarted + ).toBe(true); expect(process.env.WORKFLOW_TARGET_WORLD).toBeUndefined(); expect(resolveQueueNamespace()).toBe('configured'); }); diff --git a/packages/cli/src/lib/inspect/setup.ts b/packages/cli/src/lib/inspect/setup.ts index 3c4ab20c89..ae0a21fa6d 100644 --- a/packages/cli/src/lib/inspect/setup.ts +++ b/packages/cli/src/lib/inspect/setup.ts @@ -1,8 +1,7 @@ import { createRuntimeWorkflowConfig } from '@workflow/config/load'; import { setRuntimeWorkflowConfig } from '@workflow/config/runtime'; -import { createWorld, setWorld } from '@workflow/core/runtime'; +import { getWorld, setWorld } from '@workflow/core/runtime'; import { isVercelWorldTarget } from '@workflow/utils'; -import type { World } from '@workflow/world'; import { createVercelWorld } from '@workflow/world-vercel'; import chalk from 'chalk'; import terminalLink from 'terminal-link'; @@ -134,11 +133,10 @@ export const setupCliWorld = async ( logger.debug('Initializing world'); - let world: World; if (vercelEnvVars) { // Build the Vercel world directly from the inferred config, rather than // relying on createWorld() reading process.env. - world = createVercelWorld({ + const world = createVercelWorld({ token: vercelEnvVars.token, projectConfig: { environment: vercelEnvVars.environment, @@ -147,11 +145,9 @@ export const setupCliWorld = async ( teamId: vercelEnvVars.teamId, }, }); - } else { - world = await createWorld(); + setWorld(world); + return world; } - // Store in the global cache so BaseCommand.finally() can find and close it. - setWorld(world); - return world; + return getWorld(); }; diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index 7f67e4688a..3c9926ec58 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -190,6 +190,21 @@ export default () => ({}); ); }); + it.each([ + 'file:///tmp/world.mjs', + 'data:text/javascript,export default () => ({})', + 'node:fs', + 'C:\\world.mjs', + ])('rejects non-package World specifier %s', async (world) => { + const project = createProject({ + 'workflow.config.ts': `export default { world: ${JSON.stringify(world)} };`, + }); + + 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 ad730515c7..73767dc3d6 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -14,6 +14,7 @@ import { join, relative, resolve, + win32, } from 'node:path'; import type { WorldProvider } from '@workflow/world'; import { findUp } from 'find-up'; @@ -116,7 +117,10 @@ export async function loadWorkflowConfig( const runtimeDir = join(dirname(path), 'node_modules', '.cache', 'workflow'); let world = config.world; assert( - !world || !isAbsolute(world), + !world || + (!isAbsolute(world) && + !win32.isAbsolute(world) && + !/^[a-z][a-z\d+.-]*:/i.test(world)), `World module must be a relative path or package specifier: ${world}` ); if (world?.startsWith('.')) { From 36486684f0fa3a879117823baa928f71c3f7ed3e Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:48:06 -0700 Subject: [PATCH 22/28] Fix runtime World replacement consistency Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/core/src/runtime.test.ts | 33 ++++++++++++ packages/core/src/runtime.ts | 9 +++- .../core/src/runtime/step-handler.test.ts | 1 + packages/core/src/runtime/step-handler.ts | 11 +++- .../core/src/runtime/world-config.test.ts | 52 ++++++++++++++++++ packages/core/src/runtime/world.ts | 54 ++++++++++++++++--- packages/next/src/index.test.ts | 1 + packages/next/src/index.ts | 2 +- 8 files changed, 151 insertions(+), 12 deletions(-) diff --git a/packages/core/src/runtime.test.ts b/packages/core/src/runtime.test.ts index ea4dcdbd2d..1166111ea8 100644 --- a/packages/core/src/runtime.test.ts +++ b/packages/core/src/runtime.test.ts @@ -125,6 +125,39 @@ async function runWorkflowHandlerWithEvents( return createdEvents; } +describe('workflowEntrypoint World replacement', () => { + afterEach(() => { + setWorld(undefined); + }); + + it('rebuilds its queue handler after the World changes', async () => { + const firstHandler = vi.fn(async () => new Response('first')); + const secondHandler = vi.fn(async () => new Response('second')); + const firstCreate = vi.fn(() => firstHandler); + const secondCreate = vi.fn(() => secondHandler); + const entrypoint = workflowEntrypoint(''); + + setWorld({ + specVersion: SPEC_VERSION_CURRENT, + createQueueHandler: firstCreate, + } as any); + expect( + await (await entrypoint(new Request('https://example.test'))).text() + ).toBe('first'); + + setWorld(undefined); + setWorld({ + specVersion: SPEC_VERSION_CURRENT, + createQueueHandler: secondCreate, + } as any); + expect( + await (await entrypoint(new Request('https://example.test'))).text() + ).toBe('second'); + expect(firstCreate).toHaveBeenCalledOnce(); + expect(secondCreate).toHaveBeenCalledOnce(); + }); +}); + describe('workflowEntrypoint replay guards', () => { afterEach(() => { setWorld(undefined); diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index e48aaaacae..4801c2b1bf 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -51,6 +51,7 @@ import { handleSuspension } from './runtime/suspension-handler.js'; import { getWaitContinuationDispatch } from './runtime/wait-continuation.js'; import { getWorld, + getWorldGeneration, getWorldHandlers, type WorldHandlers, } from './runtime/world.js'; @@ -2025,6 +2026,7 @@ export function workflowEntrypoint( ); let cachedHandler: ((req: Request) => Promise) | undefined; + let cachedWorldGeneration = -1; let invocationCount = 0; const entrypointCreatedAt = Date.now(); const routeModuleBodyInitMs = @@ -2034,7 +2036,9 @@ export function workflowEntrypoint( return withHealthCheck(async (req) => { invocationCount += 1; - const handlerCached = cachedHandler !== undefined; + const worldGeneration = getWorldGeneration(); + const handlerCached = + cachedHandler !== undefined && cachedWorldGeneration === worldGeneration; const spanKind = await getSpanKind('SERVER'); return trace( @@ -2056,7 +2060,7 @@ export function workflowEntrypoint( }, }, async (span) => { - if (!cachedHandler) { + if (!cachedHandler || cachedWorldGeneration !== worldGeneration) { cachedHandler = await trace('workflow.route.init', async () => { const worldHandlers = await trace( 'workflow.route.get_world_handlers', @@ -2064,6 +2068,7 @@ export function workflowEntrypoint( ); return handler(worldHandlers); }); + cachedWorldGeneration = getWorldGeneration(); } const response = await cachedHandler(req); diff --git a/packages/core/src/runtime/step-handler.test.ts b/packages/core/src/runtime/step-handler.test.ts index dc5a4bd670..016f80d4d9 100644 --- a/packages/core/src/runtime/step-handler.test.ts +++ b/packages/core/src/runtime/step-handler.test.ts @@ -67,6 +67,7 @@ vi.mock('@vercel/functions', () => ({ // Mock the world module - createQueueHandler captures the handler vi.mock('./world.js', () => ({ + getWorldGeneration: vi.fn(() => 0), getWorld: vi.fn(async () => ({ events: { create: mockEventsCreate }, queue: mockQueue, diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index 2a8ba2631f..e029c79384 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -58,7 +58,12 @@ import { withHealthCheck, } from './helpers.js'; import { safeWaitUntil } from './wait-until.js'; -import { getWorld, getWorldHandlers, type WorldHandlers } from './world.js'; +import { + getWorld, + getWorldGeneration, + getWorldHandlers, + type WorldHandlers, +} from './world.js'; const DEFAULT_STEP_MAX_RETRIES = 3; @@ -1161,10 +1166,12 @@ const stepHandler = createStepHandler(); * for each step, this is temporary. */ let cachedStepHandler: ((req: Request) => Promise) | undefined; +let cachedWorldGeneration = -1; export const stepEntrypoint: (req: Request) => Promise = /* @__PURE__ */ withHealthCheck(async (req) => { - if (!cachedStepHandler) { + if (!cachedStepHandler || cachedWorldGeneration !== getWorldGeneration()) { cachedStepHandler = stepHandler(await getWorldHandlers()); + cachedWorldGeneration = getWorldGeneration(); } return cachedStepHandler(req); }); diff --git a/packages/core/src/runtime/world-config.test.ts b/packages/core/src/runtime/world-config.test.ts index 24f9eb1ee6..9a810953ce 100644 --- a/packages/core/src/runtime/world-config.test.ts +++ b/packages/core/src/runtime/world-config.test.ts @@ -136,4 +136,56 @@ describe('configured World', () => { await expect(getWorld()).resolves.toBe(second); expect(create).toHaveBeenCalledTimes(2); }); + + it('waits for cleanup before starting a replacement World', async () => { + delete process.env.WORKFLOW_TARGET_WORLD; + let finishClose!: () => void; + const closing = new Promise((resolve) => { + finishClose = resolve; + }); + const first = { + start: vi.fn(async () => {}), + close: vi.fn(() => closing), + } 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 getWorld(); + const close = closeWorld(); + const replacement = getWorld(); + expect(() => setWorld(second)).toThrow( + 'Cannot replace a World while it is closing.' + ); + expect(create).toHaveBeenCalledOnce(); + + finishClose(); + await close; + await expect(replacement).resolves.toBe(second); + expect(create).toHaveBeenCalledTimes(2); + }); + + it('keeps a World cached when cleanup fails', async () => { + delete process.env.WORKFLOW_TARGET_WORLD; + const world = { + start: vi.fn(async () => {}), + close: vi + .fn() + .mockRejectedValueOnce(new Error('close failed')) + .mockResolvedValueOnce(undefined), + } as unknown as World; + const create = vi.fn(() => world); + setRuntimeWorkflowConfig({ world: create }); + + await getWorld(); + await expect(closeWorld()).rejects.toThrow('close failed'); + await expect(getWorld()).resolves.toBe(world); + expect(create).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index 446413990b..b2a08dea26 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -30,14 +30,26 @@ const ManagedWorldCache = Symbol.for('@workflow/world//managedCache'); const ManagedWorldCachePromise = Symbol.for( '@workflow/world//managedCachePromise' ); +const WorldClosePromise = Symbol.for('@workflow/world//closePromise'); +const WorldGeneration = Symbol.for('@workflow/world//generation'); const globalSymbols: typeof globalThis & { [WorldCache]?: World; [WorldCachePromise]?: Promise; [ManagedWorldCache]?: boolean; [ManagedWorldCachePromise]?: boolean; + [WorldClosePromise]?: Promise; + [WorldGeneration]?: number; } = globalThis; +export function getWorldGeneration(): number { + return globalSymbols[WorldGeneration] ?? 0; +} + +function advanceWorldGeneration(): void { + globalSymbols[WorldGeneration] = getWorldGeneration() + 1; +} + function getWorkflowConfig() { return boundWorkflowConfig ?? getRuntimeWorkflowConfig() ?? {}; } @@ -197,6 +209,11 @@ export const getWorldHandlers = async (): Promise => { }; export const getWorld = async (): Promise => { + const closingWorld = globalSymbols[WorldClosePromise]; + if (closingWorld) { + await closingWorld; + } + if (globalSymbols[WorldCache]) { return globalSymbols[WorldCache]; } @@ -234,6 +251,7 @@ export const getWorld = async (): Promise => { globalSymbols[ManagedWorldCache] = pendingWorldIsManaged; globalSymbols[WorldCachePromise] = undefined; globalSymbols[ManagedWorldCachePromise] = undefined; + advanceWorldGeneration(); } return world; } catch (error) { @@ -247,6 +265,10 @@ export const getWorld = async (): Promise => { /** Override or clear an unmanaged cached World. */ export const setWorld = (world: World | undefined): void => { + assert( + !globalSymbols[WorldClosePromise], + 'Cannot replace a World while it is closing.' + ); assert( !globalSymbols[ManagedWorldCache] && !globalSymbols[ManagedWorldCachePromise], @@ -257,22 +279,40 @@ export const setWorld = (world: World | undefined): void => { globalSymbols[WorldCachePromise] = undefined; globalSymbols[ManagedWorldCache] = undefined; globalSymbols[ManagedWorldCachePromise] = undefined; + advanceWorldGeneration(); }; /** * Close the cached World without creating one just for cleanup. */ export const closeWorld = async (): Promise => { + if (globalSymbols[WorldClosePromise]) { + return globalSymbols[WorldClosePromise]; + } + const cachedWorld = globalSymbols[WorldCache]; const pendingWorld = globalSymbols[WorldCachePromise]; + const closePromise = (async () => { + const world = + cachedWorld ?? (pendingWorld ? await pendingWorld : undefined); + await world?.close?.(); + })(); + globalSymbols[WorldClosePromise] = closePromise; - globalSymbols[WorldCache] = undefined; - globalSymbols[WorldCachePromise] = undefined; - globalSymbols[ManagedWorldCache] = undefined; - globalSymbols[ManagedWorldCachePromise] = undefined; - - const world = cachedWorld ?? (pendingWorld ? await pendingWorld : undefined); - await world?.close?.(); + try { + await closePromise; + if (globalSymbols[WorldClosePromise] === closePromise) { + globalSymbols[WorldCache] = undefined; + globalSymbols[WorldCachePromise] = undefined; + globalSymbols[ManagedWorldCache] = undefined; + globalSymbols[ManagedWorldCachePromise] = undefined; + advanceWorldGeneration(); + } + } finally { + if (globalSymbols[WorldClosePromise] === closePromise) { + globalSymbols[WorldClosePromise] = undefined; + } + } }; // Register getWorld on globalThis so getWorldLazy can call it directly when diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 8a2de1c860..f6f5e46097 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -319,6 +319,7 @@ describe('withWorkflow builder config', () => { expect(process.env.PORT).toBe('4000'); expect(observedBaseUrl).toBe('http://localhost:4000'); expect(process.env.WORKFLOW_TARGET_WORLD).toBeUndefined(); + expect(process.env.WORKFLOW_LOCAL_DATA_DIR).toBe('.next/workflow-data'); expect(builderConfigs[0]).toMatchObject({ dirs: ['jobs'], projectRoot: '/explicit-root', diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index fb9edc2c61..d4a04069fe 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -377,9 +377,9 @@ export function withWorkflow( const runtimeConfigPath = loadedWorkflowConfig.runtimePath; if (!process.env.VERCEL_DEPLOYMENT_ID) { + process.env.WORKFLOW_LOCAL_DATA_DIR ??= '.next/workflow-data'; if (!workflowConfig.world && !process.env.WORKFLOW_TARGET_WORLD) { process.env.WORKFLOW_TARGET_WORLD = 'local'; - process.env.WORKFLOW_LOCAL_DATA_DIR = '.next/workflow-data'; } if (workflows?.local?.port !== undefined) { process.env.PORT = workflows.local.port.toString(); From b9fd441d5bd77a3c084cfbea9faeb7f6bafbf302 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:57:44 -0700 Subject: [PATCH 23/28] Simplify shared config plumbing Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .changeset/lazy-world-factories.md | 1 + .../workflow-runtime/get-world-handlers.mdx | 4 +- .../workflow-runtime/workflow-entrypoint.mdx | 2 +- .../docs/v5/foundations/configuration.mdx | 19 +-- packages/astro/src/builder.ts | 31 +---- packages/astro/src/plugin.ts | 8 +- packages/builders/package.json | 1 - packages/builders/src/base-builder.ts | 38 ++++-- packages/builders/src/config-helpers.ts | 34 +----- packages/builders/src/get-input-files.test.ts | 3 - packages/builders/src/index.ts | 2 + .../builders/src/resolve-sourcemap.test.ts | 9 +- packages/builders/src/standalone.ts | 3 +- packages/builders/src/types.ts | 37 ++---- .../src/vercel-build-output-api.test.ts | 18 --- .../builders/src/vercel-build-output-api.ts | 14 +-- packages/cli/src/commands/build.ts | 14 ++- .../src/lib/config/workflow-config.test.ts | 8 +- .../cli/src/lib/config/workflow-config.ts | 44 +++---- packages/config/src/load.test.ts | 15 +-- packages/config/src/load.ts | 15 ++- packages/config/src/schema.ts | 7 +- packages/core/src/runtime.ts | 29 ++--- packages/core/src/runtime/get-world-lazy.ts | 18 +-- .../core/src/runtime/step-handler.test.ts | 34 +++--- packages/core/src/runtime/step-handler.ts | 17 ++- .../core/src/runtime/world-config.test.ts | 2 + packages/core/src/runtime/world.ts | 109 ++++++++---------- packages/next/src/builder-eager.ts | 11 +- packages/next/src/index.ts | 14 +-- packages/nitro/src/builders.ts | 8 +- packages/nitro/src/index.test.ts | 6 +- packages/sveltekit/src/builder.ts | 18 +-- packages/sveltekit/src/plugin.ts | 4 +- packages/world-local/src/index.ts | 13 ++- packages/world-local/src/reenqueue.test.ts | 28 ++++- packages/world-postgres/HOW_IT_WORKS.md | 2 +- packages/world-postgres/src/index.ts | 13 ++- packages/world/src/recovery.ts | 14 +-- pnpm-lock.yaml | 3 - 40 files changed, 294 insertions(+), 376 deletions(-) delete mode 100644 packages/builders/src/vercel-build-output-api.test.ts diff --git a/.changeset/lazy-world-factories.md b/.changeset/lazy-world-factories.md index aad0065a71..37ad40477a 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-local": patch "@workflow/world-postgres": patch --- diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx index 010b6b4d67..be2b8943cb 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx @@ -11,8 +11,6 @@ Returns `createQueueHandler` and `specVersion` from the same managed [World](/docs/api-reference/workflow-runtime/world) as [`getWorld()`](/docs/api-reference/workflow-runtime/get-world). The first call resolves and caches the World, starting configured providers before returning. -Generated route handlers call this lazily when they receive their first -request. ```typescript lineNumbers import { getWorldHandlers } from "workflow/runtime"; @@ -38,7 +36,7 @@ type WorldHandlers = Pick; ``` - This is SDK infrastructure used by framework adapters and the workflow entrypoint. Application code should use [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) instead. + This is a low-level infrastructure API. Application code should use [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) instead. ## Related Functions diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx index 084136fc3a..f23acfea8c 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx @@ -38,5 +38,5 @@ Returns a fetch-style request handler: `(req: Request) => Promise`. ## Related Functions -- [`getWorldHandlers()`](/docs/api-reference/workflow-runtime/get-world-handlers) - Resolve queue handlers from the managed World. +- [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the World used by the handler. - [`healthCheck()`](/docs/api-reference/workflow-runtime/health-check) - Verify the entrypoint processes queue messages end-to-end. diff --git a/docs/content/docs/v5/foundations/configuration.mdx b/docs/content/docs/v5/foundations/configuration.mdx index b1ed2c4601..01bf37beb7 100644 --- a/docs/content/docs/v5/foundations/configuration.mdx +++ b/docs/content/docs/v5/foundations/configuration.mdx @@ -30,8 +30,8 @@ const config = { export default config; ``` -The World module default-exports a lazy provider. Keep runtime credentials in -this module or environment variables rather than the build configuration: +The World module default-exports a lazy provider. Read runtime credentials from +environment variables here rather than placing them in the build configuration: ```typescript title="workflow.world.ts" lineNumbers import type { WorldProvider } from "workflow/config"; @@ -68,8 +68,8 @@ From highest to lowest priority: `world` is an optional path relative to `workflow.config.ts`, or a package specifier. Its module must default-export a `WorldProvider`. The module is -bundled during the build, but the provider runs only when the application first -needs its World. +included in the runtime output, but the provider runs only when the application +first needs its World. World selection follows this order: @@ -133,16 +133,19 @@ database. | --- | --- | --- | | `build.dirs` | `string[]` | Directories to scan for workflow code | | `build.projectRoot` | `string` | Root for tracing, module resolution, and TypeScript configuration | -| `build.externalPackages` | `string[]` | Packages excluded from generated bundles. Not supported by the direct `vercel-build-output-api` target | +| `build.externalPackages` | `string[]` | Packages excluded from generated bundles. Direct Vercel Build Output API builds bundle all dependencies. | | `build.sourcemap` | `boolean \| "inline" \| "linked" \| "external" \| "both"` | Source map mode for generated bundles | | `build.manifest.public` | `boolean` | Expose the workflow manifest over HTTP | | `build.manifest.output` | `string` | Write the workflow manifest to this path | ## Queue Namespace -`queue.namespace` prefixes generated workflow and step queues so multiple -Workflow applications can share infrastructure without colliding. It must be -lowercase alphanumeric and start with a letter. +`queue.namespace` prefixes Workflow queue topics and generated deployment +triggers. It must be lowercase alphanumeric and start with a letter. + +For Postgres Worlds that share a database, also give each application a unique +`jobPrefix`. The namespace distinguishes Workflow topics; `jobPrefix` +distinguishes the underlying Graphile Worker tasks. ## Integration Settings diff --git a/packages/astro/src/builder.ts b/packages/astro/src/builder.ts index c3c5d57fd9..cc50ed9fd3 100644 --- a/packages/astro/src/builder.ts +++ b/packages/astro/src/builder.ts @@ -3,7 +3,6 @@ import { join, resolve } from 'node:path'; import { type AstroConfig, BaseBuilder, - createBaseBuilderConfig, NORMALIZE_REQUEST_CODE, VercelBuildOutputAPIBuilder, } from '@workflow/builders'; @@ -28,18 +27,8 @@ export class LocalBuilder extends BaseBuilder { ...config, dirs: config.dirs ?? build?.dirs ?? ['src/pages', 'src/workflows'], buildTarget: 'astro' as const, - stepsBundlePath: '', // unused in base - workflowsBundlePath: '', // unused in base - webhookBundlePath: '', // unused in base workingDir, - projectRoot: - config.projectRoot ?? - (build?.projectRoot - ? resolve(workingDir, build.projectRoot) - : undefined), - externalPackages: config.externalPackages ?? build?.externalPackages, debugFilePrefix: '_', // Prefix with underscore so Astro ignores debug files - sourcemap: config.sourcemap, }); } @@ -102,9 +91,11 @@ export const prerender = false;` // Expose manifest as a public HTTP route when WORKFLOW_PUBLIC_MANIFEST=1 // Astro maps `foo.json.js` to the URL `/foo.json` + const publicManifestPath = join(workflowGeneratedDir, 'manifest.json.js'); + await rm(publicManifestPath, { force: true }); if (this.shouldExposePublicManifest && manifestJson) { await writeFile( - join(workflowGeneratedDir, 'manifest.json.js'), + publicManifestPath, `export function GET() { return new Response(${JSON.stringify(manifestJson)}, { headers: { "content-type": "application/json" }, @@ -174,19 +165,9 @@ export class VercelBuilder extends VercelBuildOutputAPIBuilder { const workingDir = config.workingDir ?? process.cwd(); const build = config.workflowConfig?.config.build; super({ - ...createBaseBuilderConfig({ - workingDir, - dirs: config.dirs ?? build?.dirs ?? ['src/pages', 'src/workflows'], - projectRoot: - config.projectRoot ?? - (build?.projectRoot - ? resolve(workingDir, build.projectRoot) - : undefined), - externalPackages: config.externalPackages ?? build?.externalPackages, - runtime: config.runtime, - sourcemap: config.sourcemap, - workflowConfig: config.workflowConfig, - }), + ...config, + workingDir, + dirs: config.dirs ?? build?.dirs ?? ['src/pages', 'src/workflows'], buildTarget: 'vercel-build-output-api', debugFilePrefix: '_', }); diff --git a/packages/astro/src/plugin.ts b/packages/astro/src/plugin.ts index 6dc8c44e0b..07fd8f5365 100644 --- a/packages/astro/src/plugin.ts +++ b/packages/astro/src/plugin.ts @@ -1,4 +1,5 @@ import { createBuildQueue } from '@workflow/builders'; +import type { SourcemapMode } from '@workflow/config'; import { loadWorkflowConfig } from '@workflow/config/load'; import { workflowTransformPlugin } from '@workflow/rollup'; import { workflowHotUpdatePlugin } from '@workflow/vite'; @@ -12,13 +13,16 @@ export interface WorkflowPluginOptions { * `'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; } export function workflowPlugin( options: WorkflowPluginOptions = {} ): AstroIntegration { - const workflowConfig = loadWorkflowConfig({ cwd: process.cwd() }); + const workflowConfig = loadWorkflowConfig({ + cwd: process.cwd(), + integration: 'astro', + }); let builder: LocalBuilder; const enqueue = createBuildQueue(); diff --git a/packages/builders/package.json b/packages/builders/package.json index 81a0c31621..e967a9d0e7 100644 --- a/packages/builders/package.json +++ b/packages/builders/package.json @@ -45,7 +45,6 @@ "@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 0285571aa1..11ceeaa470 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -36,7 +36,7 @@ import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js'; import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js'; import { createSwcPlugin } from './swc-esbuild-plugin.js'; import { detectWorkflowPatterns } from './transform-utils.js'; -import type { SourcemapMode, WorkflowConfig } from './types.js'; +import type { BuilderConfig, SourcemapMode } from './types.js'; import { extractWorkflowGraphs } from './workflows-extractor.js'; const enhancedResolve = promisify(enhancedResolveOriginal); @@ -215,8 +215,10 @@ function mergeWorkflowManifest( * * Subclasses must implement the build() method to define builder-specific logic. */ -export abstract class BaseBuilder { - protected config: WorkflowConfig; +export abstract class BaseBuilder< + TConfig extends BuilderConfig = BuilderConfig, +> { + protected config: TConfig; /** * Tracks which external packages have already been warned about @@ -226,12 +228,17 @@ export abstract class BaseBuilder { private workflowBuildStartTime: number | undefined; private workflowBuildSummaryCount = 0; - constructor(config: WorkflowConfig) { + constructor(config: TConfig) { this.config = config; } protected get transformProjectRoot(): string { - return this.config.projectRoot || this.config.workingDir; + const projectRoot = + this.config.projectRoot ?? + this.config.workflowConfig?.config.build?.projectRoot; + return projectRoot + ? resolve(this.config.workingDir, projectRoot) + : this.config.workingDir; } protected get moduleSpecifierRoot(): string { @@ -245,6 +252,15 @@ export abstract class BaseBuilder { ); } + protected get externalPackages(): string[] { + if (this.config.buildTarget === 'vercel-build-output-api') return []; + return ( + this.config.externalPackages ?? + this.config.workflowConfig?.config.build?.externalPackages ?? + [] + ); + } + private get runtimeConfigPlugins(): esbuild.Plugin[] { const path = this.config.workflowConfig?.runtimePath; if (!path) return []; @@ -423,8 +439,8 @@ export abstract class BaseBuilder { * workflow compiler when the package is externalized. */ private async warnAboutExternalWorkflowPackages(): Promise { - const externalPackages = this.config.externalPackages; - if (!externalPackages?.length) return; + const externalPackages = this.externalPackages; + if (!externalPackages.length) return; for (const pkg of externalPackages) { if (BaseBuilder.PSEUDO_PACKAGES.has(pkg)) continue; @@ -1129,7 +1145,7 @@ export const __steps_registered = true; ], // Plugin should catch most things, but this lets users hard override // if the plugin misses anything that should be externalized - external: ['bun', 'bun:*', ...(this.config.externalPackages || [])], + external: ['bun', 'bun:*', ...this.externalPackages], }); const stepsResult = await esbuildCtx.rebuild(); @@ -1527,7 +1543,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo minify: false, external: [ '@aws-sdk/credential-provider-web-identity', - ...(this.config.externalPackages ?? []), + ...this.externalPackages, ], plugins: this.runtimeConfigPlugins, }); @@ -1719,7 +1735,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo define: importMetaDefine, external: [ '@aws-sdk/credential-provider-web-identity', - ...(this.config.externalPackages ?? []), + ...this.externalPackages, ], plugins: this.runtimeConfigPlugins, }); @@ -2003,7 +2019,7 @@ export const OPTIONS = handler;`; ], sourcemap: this.resolveSourcemap(EMIT_SOURCEMAPS_FOR_DEBUGGING), mainFields: ['module', 'main'], - external: this.config.externalPackages ?? [], + external: this.externalPackages, plugins: this.runtimeConfigPlugins, }); diff --git a/packages/builders/src/config-helpers.ts b/packages/builders/src/config-helpers.ts index 456c0701d0..40a9473e01 100644 --- a/packages/builders/src/config-helpers.ts +++ b/packages/builders/src/config-helpers.ts @@ -1,8 +1,7 @@ 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'; +import type { BaseBuilderConfig } from './types.js'; export interface DecoratorOptions { decorators: boolean; @@ -86,31 +85,8 @@ export async function getDecoratorOptionsForDirectoryWithConfigPath( return { options, configPath }; } -/** - * Creates a partial configuration for builders that don't use bundle paths directly. - * Used by framework integrations like Nitro where the builder computes paths internally. - */ -export function createBaseBuilderConfig(options: { - workingDir: string; - projectRoot?: string; - dirs?: string[]; - watch?: boolean; - externalPackages?: string[]; - runtime?: string; - sourcemap?: SourcemapMode; - workflowConfig?: LoadedWorkflowConfig; -}): Omit { - return { - dirs: options.dirs ?? ['workflows'], - projectRoot: options.projectRoot, - workingDir: options.workingDir, - watch: options.watch, - stepsBundlePath: '', // Not used by base builder methods - workflowsBundlePath: '', // Not used by base builder methods - webhookBundlePath: '', // Not used by base builder methods - externalPackages: options.externalPackages, - runtime: options.runtime, - sourcemap: options.sourcemap, - workflowConfig: options.workflowConfig, - }; +export function createBaseBuilderConfig( + config: BaseBuilderConfig +): BaseBuilderConfig { + return config; } diff --git a/packages/builders/src/get-input-files.test.ts b/packages/builders/src/get-input-files.test.ts index a284a1dadc..2afd8c9d7e 100644 --- a/packages/builders/src/get-input-files.test.ts +++ b/packages/builders/src/get-input-files.test.ts @@ -274,9 +274,6 @@ describe('getDiagnosticsManifestPath', () => { buildTarget: 'vercel-build-output-api', workingDir: testRoot, dirs: ['src'], - stepsBundlePath: join(testRoot, 'steps.js'), - workflowsBundlePath: join(testRoot, 'workflows.js'), - webhookBundlePath: join(testRoot, 'webhook.js'), }; const builder = new TestBuilder(config); diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index a1ec9443ea..27299b3ef5 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -50,6 +50,8 @@ export { } from './transform-utils.js'; export type { AstroConfig, + BaseBuilderConfig, + BuilderConfig, BuildTarget, NextConfig, StandaloneConfig, diff --git a/packages/builders/src/resolve-sourcemap.test.ts b/packages/builders/src/resolve-sourcemap.test.ts index fabee8e785..83df9d60c4 100644 --- a/packages/builders/src/resolve-sourcemap.test.ts +++ b/packages/builders/src/resolve-sourcemap.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { BaseBuilder } from './base-builder.js'; -import type { SourcemapMode, StandaloneConfig } from './types.js'; +import type { NextConfig, SourcemapMode } from './types.js'; /** * Minimal subclass that exposes the protected `resolveSourcemap()` and @@ -32,13 +32,10 @@ function createBuilder( sourcemap?: SourcemapMode, options: { watch?: boolean; workflowSourcemap?: SourcemapMode } = {} ): TestBuilder { - const config: StandaloneConfig = { - buildTarget: 'standalone', + const config: NextConfig = { + buildTarget: 'next', workingDir: '/tmp/workflow-test', dirs: ['.'], - stepsBundlePath: '', - workflowsBundlePath: '', - webhookBundlePath: '', sourcemap, watch: options.watch, workflowConfig: diff --git a/packages/builders/src/standalone.ts b/packages/builders/src/standalone.ts index 06ea4b0ce8..a6a9939410 100644 --- a/packages/builders/src/standalone.ts +++ b/packages/builders/src/standalone.ts @@ -1,6 +1,7 @@ import { BaseBuilder } from './base-builder.js'; +import type { StandaloneConfig } from './types.js'; -export class StandaloneBuilder extends BaseBuilder { +export class StandaloneBuilder extends BaseBuilder { async build(): Promise { const inputFiles = await this.getInputFiles(); const tsconfigPath = await this.findTsConfigPath(); diff --git a/packages/builders/src/types.ts b/packages/builders/src/types.ts index 3543c8275b..2fc134fb0d 100644 --- a/packages/builders/src/types.ts +++ b/packages/builders/src/types.ts @@ -16,7 +16,7 @@ export type BuildTarget = (typeof validBuildTargets)[number]; /** * Common configuration options shared across all builder types. */ -interface BaseWorkflowConfig { +export interface BaseBuilderConfig { watch?: boolean; dirs: string[]; workingDir: string; @@ -95,7 +95,7 @@ interface BaseWorkflowConfig { /** * Configuration for standalone (CLI-based) builds. */ -export interface StandaloneConfig extends BaseWorkflowConfig { +export interface StandaloneConfig extends BaseBuilderConfig { buildTarget: 'standalone'; stepsBundlePath: string; workflowsBundlePath: string; @@ -105,61 +105,42 @@ export interface StandaloneConfig extends BaseWorkflowConfig { /** * Configuration for Vercel Build Output API builds. */ -export interface VercelBuildOutputConfig extends BaseWorkflowConfig { +export interface VercelBuildOutputConfig extends BaseBuilderConfig { buildTarget: 'vercel-build-output-api'; - stepsBundlePath: string; - workflowsBundlePath: string; - webhookBundlePath: string; } /** * Configuration for Next.js builds. */ -export interface NextConfig extends BaseWorkflowConfig { +export interface NextConfig extends BaseBuilderConfig { buildTarget: 'next'; - // Next.js builder computes paths dynamically, so these are not used - stepsBundlePath: string; - workflowsBundlePath: string; - webhookBundlePath: string; } /** * Configuration for SvelteKit builds. */ -export interface SvelteKitConfig extends BaseWorkflowConfig { +export interface SvelteKitConfig extends BaseBuilderConfig { buildTarget: 'sveltekit'; - // SvelteKit builder computes paths dynamically, so these are not used - stepsBundlePath: string; - workflowsBundlePath: string; - webhookBundlePath: string; } /** * Configuration for Astro builds. */ -export interface AstroConfig extends BaseWorkflowConfig { +export interface AstroConfig extends BaseBuilderConfig { buildTarget: 'astro'; - // Astro builder computes paths dynamically, so these are not used - stepsBundlePath: string; - workflowsBundlePath: string; - webhookBundlePath: string; } /** * Configuration for NestJS builds. */ -export interface NestConfig extends BaseWorkflowConfig { +export interface NestConfig extends BaseBuilderConfig { buildTarget: 'nest'; - // NestJS builder computes paths dynamically, so these are not used - stepsBundlePath: string; - workflowsBundlePath: string; - webhookBundlePath: string; } /** * Discriminated union of all builder configuration types. */ -export type WorkflowConfig = +export type BuilderConfig = | StandaloneConfig | VercelBuildOutputConfig | NextConfig @@ -167,6 +148,8 @@ export type WorkflowConfig = | SvelteKitConfig | AstroConfig; +export type WorkflowConfig = BuilderConfig; + export function isValidBuildTarget( target: string | undefined ): target is BuildTarget { diff --git a/packages/builders/src/vercel-build-output-api.test.ts b/packages/builders/src/vercel-build-output-api.test.ts deleted file mode 100644 index d7fafa3828..0000000000 --- a/packages/builders/src/vercel-build-output-api.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { expect, it } from 'vitest'; -import { VercelBuildOutputAPIBuilder } from './vercel-build-output-api.js'; - -it('rejects external packages because the target does not trace them', async () => { - const builder = new VercelBuildOutputAPIBuilder({ - buildTarget: 'vercel-build-output-api', - dirs: ['.'], - workingDir: process.cwd(), - externalPackages: ['database-client'], - stepsBundlePath: '', - workflowsBundlePath: '', - webhookBundlePath: '', - }); - - await expect(builder.build()).rejects.toThrow( - 'build.externalPackages is not supported by the vercel-build-output-api target.' - ); -}); diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts index aa70ec2a3c..4863554633 100644 --- a/packages/builders/src/vercel-build-output-api.ts +++ b/packages/builders/src/vercel-build-output-api.ts @@ -1,16 +1,10 @@ -import assert from 'node:assert/strict'; -import { copyFile, mkdir, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { BaseBuilder } from './base-builder.js'; import { createWorkflowQueueTrigger } from './constants.js'; export class VercelBuildOutputAPIBuilder extends BaseBuilder { async build(): Promise { - assert( - !this.config.externalPackages?.length, - 'build.externalPackages is not supported by the vercel-build-output-api target.' - ); - const outputDir = resolve(this.config.workingDir, '.vercel/output'); const functionsDir = join(outputDir, 'functions'); const workflowGeneratedDir = join(functionsDir, '.well-known/workflow/v1'); @@ -66,11 +60,9 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder { // Expose manifest as a static file when WORKFLOW_PUBLIC_MANIFEST=1. // Vercel Build Output API serves static files from .vercel/output/static/ + const staticManifestDir = join(outputDir, 'static/.well-known/workflow/v1'); + await rm(join(staticManifestDir, 'manifest.json'), { force: true }); if (this.shouldExposePublicManifest && manifestJson) { - const staticManifestDir = join( - outputDir, - 'static/.well-known/workflow/v1' - ); await mkdir(staticManifestDir, { recursive: true }); if (process.env.VERCEL_DEPLOYMENT_ID === undefined) { await writeFile(join(staticManifestDir, '.gitignore'), '*'); diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index f2e28bef11..0b888e07ac 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -4,7 +4,6 @@ import { VercelBuildOutputAPIBuilder, } from '@workflow/builders'; import { BaseCommand } from '../base.js'; -import { type BuildTarget, isValidBuildTarget } from '../lib/config/types.js'; import { getWorkflowConfig } from '../lib/config/workflow-config.js'; export default class Build extends BaseCommand { @@ -61,7 +60,10 @@ export default class Build extends BaseCommand { } // Validate build target - if (!isValidBuildTarget(buildTarget)) { + if ( + buildTarget !== 'standalone' && + buildTarget !== 'vercel-build-output-api' + ) { this.logWarn( `Invalid target "${buildTarget}". Using default "standalone".` ); @@ -69,10 +71,14 @@ export default class Build extends BaseCommand { buildTarget = 'standalone'; } - this.logInfo(`Using target: ${buildTarget}`); + const target = + buildTarget === 'vercel-build-output-api' + ? 'vercel-build-output-api' + : 'standalone'; + this.logInfo(`Using target: ${target}`); const config = await getWorkflowConfig({ - buildTarget: buildTarget as BuildTarget, + buildTarget: target, workflowManifest: flags['workflow-manifest'], configFile: flags.config, }); diff --git a/packages/cli/src/lib/config/workflow-config.test.ts b/packages/cli/src/lib/config/workflow-config.test.ts index e5f2a1f8e6..d4ebfdb07b 100644 --- a/packages/cli/src/lib/config/workflow-config.test.ts +++ b/packages/cli/src/lib/config/workflow-config.test.ts @@ -19,12 +19,16 @@ describe('getWorkflowConfig', () => { it('scans the project by default and honors configured directories', async () => { process.env.WORKFLOW_OBSERVABILITY_CWD = workingDir; - expect((await getWorkflowConfig()).dirs).toEqual(['.']); + expect( + (await getWorkflowConfig({ buildTarget: 'standalone' })).dirs + ).toEqual(['.']); writeFileSync( join(workingDir, 'workflow.config.ts'), `export default { build: { dirs: ['jobs'] } };` ); - expect((await getWorkflowConfig()).dirs).toEqual(['jobs']); + expect( + (await getWorkflowConfig({ buildTarget: 'standalone' })).dirs + ).toEqual(['jobs']); }); }); diff --git a/packages/cli/src/lib/config/workflow-config.ts b/packages/cli/src/lib/config/workflow-config.ts index b1055f908d..1175d27145 100644 --- a/packages/cli/src/lib/config/workflow-config.ts +++ b/packages/cli/src/lib/config/workflow-config.ts @@ -6,6 +6,11 @@ import { import { config as loadDotEnv } from 'dotenv'; import type { BuildTarget, WorkflowConfig } from './types.js'; +type CliBuildTarget = Extract< + BuildTarget, + 'standalone' | 'vercel-build-output-api' +>; + export function resolveWorkflowCwd(): string { const raw = process.env.WORKFLOW_OBSERVABILITY_CWD; if (!raw) { @@ -25,35 +30,32 @@ export async function loadProjectWorkflowConfig( return loadWorkflowConfig({ cwd, configFile }); } -export const getWorkflowConfig = async ( - options: { - buildTarget?: BuildTarget; - workflowManifest?: string; - configFile?: string; - } = {} -): Promise => { - const { buildTarget = 'standalone', workflowManifest, configFile } = options; +export const getWorkflowConfig = async (options: { + buildTarget: CliBuildTarget; + workflowManifest?: string; + configFile?: string; +}): Promise => { + const { buildTarget, workflowManifest, configFile } = options; const workingDir = resolveWorkflowCwd(); const loadedConfig = await loadProjectWorkflowConfig(configFile); const fileConfig = loadedConfig.config; - const config: WorkflowConfig = { + const config = { dirs: fileConfig.build?.dirs ?? (buildTarget === 'standalone' ? ['.'] : ['./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', workflowManifestPath: workflowManifest, - - // WIP: generate a client library to easily execute workflows/steps - // clientBundlePath: './lib/generated/workflows.js', }; - return config; + if (buildTarget === 'standalone') { + return { + ...config, + buildTarget, + stepsBundlePath: './.well-known/workflow/v1/step.mjs', + workflowsBundlePath: './.well-known/workflow/v1/flow.mjs', + webhookBundlePath: './.well-known/workflow/v1/webhook.mjs', + }; + } + + return { ...config, buildTarget }; }; diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index 3c9926ec58..f7385853b6 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -69,20 +69,6 @@ describe('loadWorkflowConfig', () => { expect(getRuntimeWorkflowConfig()).toBe(runtime.default); }); - it('uses the build environment namespace as the runtime fallback', async () => { - process.env.WORKFLOW_QUEUE_NAMESPACE = 'deployment'; - const project = createProject({ - 'workflow.config.ts': `export default { queue: { namespace: 'app' } };`, - }); - const loaded = await loadWorkflowConfig({ cwd: project }); - const runtime = (await import( - pathToFileURL(loaded.runtimePath as string).href - )) as { default: RuntimeWorkflowConfig }; - - expect(loaded.config.queue).toEqual({ namespace: 'app' }); - expect(runtime.default.queue).toEqual({ namespace: 'deployment' }); - }); - it('loads the nearest TypeScript config without merging parents', async () => { const project = createProject({ 'workflow.config.ts': `export default { build: { dirs: ['parent'] } };`, @@ -99,6 +85,7 @@ describe('loadWorkflowConfig', () => { }); expect(loaded.path).toBe(join(app, 'workflow.config.ts')); + expect(loaded.runtimePath).toBeUndefined(); expect(loaded.config).toEqual({ build: { dirs: ['app'], sourcemap: false }, integration: { type: 'next' }, diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 73767dc3d6..52abc373a1 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -40,7 +40,11 @@ export type LoadWorkflowConfigOptions = { export type LoadedWorkflowConfig = | { path: undefined; runtimePath: undefined; config: WorkflowConfig } - | { path: string; runtimePath: string; config: WorkflowConfig }; + | { + path: string; + runtimePath: string | undefined; + config: WorkflowConfig; + }; type FoundWorkflowConfig = Extract; @@ -114,6 +118,10 @@ export async function loadWorkflowConfig( `${basename(path)} configures "${config.integration?.type}" but was loaded by "${options.integration}".` ); + if (!config.world && !config.queue) { + return { path, runtimePath: undefined, config }; + } + const runtimeDir = join(dirname(path), 'node_modules', '.cache', 'workflow'); let world = config.world; assert( @@ -139,13 +147,10 @@ export async function loadWorkflowConfig( const worldFactory = world ? `async () => { const provider = (await import(${JSON.stringify(world)})).default; return provider(); }` : 'undefined'; - const namespace = - process.env.WORKFLOW_QUEUE_NAMESPACE ?? config.queue?.namespace; - const queue = namespace === undefined ? undefined : { namespace }; mkdirSync(runtimeDir, { recursive: true }); writeFileSync( runtimePath, - `const world = ${worldFactory};\nconst config = { world, queue: ${JSON.stringify(queue)} };\nglobalThis[Symbol.for('@workflow/config/runtime')] = config;\nglobalThis[Symbol.for('@workflow/queue/namespace')] = config.queue?.namespace;\nexport default config;\n` + `const world = ${worldFactory};\nconst config = { world, queue: ${JSON.stringify(config.queue)} };\nglobalThis[Symbol.for('@workflow/config/runtime')] = config;\nexport default config;\n` ); return { path, runtimePath, config }; diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 03b05a6b07..e75ed40622 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -43,6 +43,7 @@ export const WorkflowConfigSchema = z.strictObject({ }); export type WorkflowConfig = z.infer; -export type WorkflowIntegrationType = NonNullable< - WorkflowConfig['integration'] ->['type']; +export type WorkflowIntegrationType = + | NonNullable['type'] + | 'astro' + | 'sveltekit'; diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 4801c2b1bf..bde358d031 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -49,12 +49,7 @@ import { import { executeStep } from './runtime/step-executor.js'; import { handleSuspension } from './runtime/suspension-handler.js'; import { getWaitContinuationDispatch } from './runtime/wait-continuation.js'; -import { - getWorld, - getWorldGeneration, - getWorldHandlers, - type WorldHandlers, -} from './runtime/world.js'; +import { getWorld, type WorldHandlers } from './runtime/world.js'; import { dehydrateRunError } from './serialization.js'; import { remapErrorStack } from './source-map.js'; import { @@ -952,7 +947,6 @@ export function workflowEntrypoint( const encryptionKey = await getEncryptionKey(); // Main replay loop - // biome-ignore lint/correctness/noConstantCondition: intentional loop while (true) { loopIteration++; @@ -2026,7 +2020,7 @@ export function workflowEntrypoint( ); let cachedHandler: ((req: Request) => Promise) | undefined; - let cachedWorldGeneration = -1; + let cachedWorld: World | undefined; let invocationCount = 0; const entrypointCreatedAt = Date.now(); const routeModuleBodyInitMs = @@ -2036,9 +2030,8 @@ export function workflowEntrypoint( return withHealthCheck(async (req) => { invocationCount += 1; - const worldGeneration = getWorldGeneration(); - const handlerCached = - cachedHandler !== undefined && cachedWorldGeneration === worldGeneration; + const world = await getWorld(); + const handlerCached = cachedHandler !== undefined && cachedWorld === world; const spanKind = await getSpanKind('SERVER'); return trace( @@ -2060,15 +2053,11 @@ export function workflowEntrypoint( }, }, async (span) => { - if (!cachedHandler || cachedWorldGeneration !== worldGeneration) { - cachedHandler = await trace('workflow.route.init', async () => { - const worldHandlers = await trace( - 'workflow.route.get_world_handlers', - async () => getWorldHandlers() - ); - return handler(worldHandlers); - }); - cachedWorldGeneration = getWorldGeneration(); + if (!cachedHandler || cachedWorld !== world) { + cachedHandler = await trace('workflow.route.init', async () => + handler(world) + ); + cachedWorld = world; } const response = await cachedHandler(req); diff --git a/packages/core/src/runtime/get-world-lazy.ts b/packages/core/src/runtime/get-world-lazy.ts index 44893fbb68..e7616ceb73 100644 --- a/packages/core/src/runtime/get-world-lazy.ts +++ b/packages/core/src/runtime/get-world-lazy.ts @@ -9,14 +9,13 @@ * * Resolution order, in priority: * - * 1. `globalThis[WorldCacheKey]` — populated by a successful prior - * `getWorld()` call. This is the steady-state hot path. - * 2. `globalThis[GetWorldFnKey]` — populated by the module-load side + * 1. `globalThis[GetWorldFnKey]` — populated by the module-load side * effect at the bottom of `./world.ts`. Fires on every server bundle * that reaches this file via `workflow/api` (which imports * `./world-init.ts` for its side effect; see that file for the full - * rationale). This is the cold-start path for routes that consume - * `start` without any prior workflow run. + * rationale). + * 2. The global World cache or pending creation promise when world.ts is not + * present in the bundle. * 3. Dynamic `import('./world.js')` — last-resort fallback for * environments where neither (1) nor (2) is available (CJS test * runners, scripts that import deeply into `@workflow/core` without @@ -36,6 +35,8 @@ const GetWorldFnKey = Symbol.for('@workflow/world//getWorldFn'); export async function getWorldLazy(): Promise { const g = globalThis as any; + const getWorldFn = g[GetWorldFnKey] as (() => Promise) | undefined; + if (getWorldFn) return getWorldFn(); if (g[WorldCacheKey]) return g[WorldCacheKey]; if (g[WorldCachePromiseKey]) { const pendingWorld = g[WorldCachePromiseKey]; @@ -45,13 +46,6 @@ export async function getWorldLazy(): Promise { } 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 - // import fallback, which doesn't survive Next.js inlining get-world-lazy - // into a route bundle (the relative './world.js' resolves against the - // bundled location, where no sibling world.js exists). - const getWorldFn = g[GetWorldFnKey] as (() => Promise) | undefined; - if (getWorldFn) return getWorldFn(); // Last resort: dynamic import for environments where world.ts wasn't // bundled but is reachable as a sibling module on disk. The specifier is // built at runtime so esbuild can't trace it into the step bundle. diff --git a/packages/core/src/runtime/step-handler.test.ts b/packages/core/src/runtime/step-handler.test.ts index 016f80d4d9..fa02252e45 100644 --- a/packages/core/src/runtime/step-handler.test.ts +++ b/packages/core/src/runtime/step-handler.test.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { EntityConflictError, FatalError, @@ -67,23 +68,19 @@ vi.mock('@vercel/functions', () => ({ // Mock the world module - createQueueHandler captures the handler vi.mock('./world.js', () => ({ - getWorldGeneration: vi.fn(() => 0), getWorld: vi.fn(async () => ({ - events: { create: mockEventsCreate }, - queue: mockQueue, - getEncryptionKeyForRun: vi.fn().mockResolvedValue(undefined), - })), - getWorldHandlers: vi.fn(async () => ({ createQueueHandler: vi.fn( ( _prefix: string, handler: (...args: unknown[]) => Promise ): ((req: Request) => Promise) => { capturedHandlerRef.current = handler; - // Return a mock request handler return vi.fn() as unknown as (req: Request) => Promise; } ), + events: { create: mockEventsCreate }, + queue: mockQueue, + getEncryptionKeyForRun: vi.fn().mockResolvedValue(undefined), })), })); @@ -193,8 +190,7 @@ import { import { MAX_QUEUE_DELIVERIES } from './constants.js'; import { executeStep } from './step-executor.js'; // Import the module AFTER all mocks are set up -// Since getWorldHandlers is now async, we need to call stepEntrypoint -// to trigger createQueueHandler and populate capturedHandlerRef +// Call stepEntrypoint to trigger createQueueHandler and populate capturedHandlerRef. import { stepEntrypoint } from './step-handler.js'; import { getWorld } from './world.js'; @@ -236,7 +232,7 @@ function createMessage(overrides: Record = {}) { describe('step-handler 409 handling', () => { // Trigger the lazy handler initialization by calling stepEntrypoint once. - // This invokes getWorldHandlers() which calls createQueueHandler and captures the handler. + // This gets the World and captures the queue handler. beforeAll(async () => { await stepEntrypoint(new Request('http://localhost')); }); @@ -284,7 +280,6 @@ describe('step-handler 409 handling', () => { describe('step_completed 409', () => { it('should warn and return when step_completed gets a 409', async () => { // step_started succeeds, step function succeeds, step_completed returns 409 - let callCount = 0; mockEventsCreate.mockImplementation( (_runId: string, event: { eventType: string }) => { if (event.eventType === 'step_started') { @@ -300,7 +295,6 @@ describe('step-handler 409 handling', () => { }); } if (event.eventType === 'step_completed') { - callCount++; return Promise.reject( new EntityConflictError( 'Cannot complete step because it is already completed' @@ -537,8 +531,8 @@ describe('step-handler 409 handling', () => { ([, event]: [string, { eventType: string }]) => event.eventType === 'step_started' ); - expect(startedCall).toBeDefined(); - expect(startedCall![2]).toEqual( + assert(startedCall); + expect(startedCall[2]).toEqual( expect.objectContaining({ requestId: 'iad1::req-abc' }) ); }); @@ -553,8 +547,8 @@ describe('step-handler 409 handling', () => { ([, event]: [string, { eventType: string }]) => event.eventType === 'step_completed' ); - expect(completedCall).toBeDefined(); - expect(completedCall![2]).toEqual( + assert(completedCall); + expect(completedCall[2]).toEqual( expect.objectContaining({ requestId: 'iad1::req-abc' }) ); }); @@ -566,8 +560,8 @@ describe('step-handler 409 handling', () => { ([, event]: [string, { eventType: string }]) => event.eventType === 'step_started' ); - expect(startedCall).toBeDefined(); - expect(startedCall![2]).toEqual( + assert(startedCall); + expect(startedCall[2]).toEqual( expect.objectContaining({ requestId: undefined }) ); }); @@ -648,7 +642,7 @@ describe('step-handler max deliveries', () => { }); it('should not trigger max deliveries check when under limit', async () => { - const result = await capturedHandler(createMessage(), { + await capturedHandler(createMessage(), { ...createMetadata('myStep'), attempt: MAX_QUEUE_DELIVERIES, }); @@ -1214,7 +1208,7 @@ describe('executeStep optimistic inline start', () => { it('sends step_started carrying the input and completes (when enabled)', async () => { mockEventsCreate .mockReset() - .mockImplementation((_runId: string, event: { eventType: string }) => + .mockImplementation((_runId: string, _event: { eventType: string }) => Promise.resolve({ event: {} }) ); diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index e029c79384..7369761aa3 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -18,6 +18,7 @@ import { SPEC_VERSION_SUPPORTS_COMPRESSION, type Step, StepInvokePayloadSchema, + type World, } from '@workflow/world'; import { describeError } from '../describe-error.js'; import { runtimeLogger, stepLogger } from '../logger.js'; @@ -58,12 +59,7 @@ import { withHealthCheck, } from './helpers.js'; import { safeWaitUntil } from './wait-until.js'; -import { - getWorld, - getWorldGeneration, - getWorldHandlers, - type WorldHandlers, -} from './world.js'; +import { getWorld, type WorldHandlers } from './world.js'; const DEFAULT_STEP_MAX_RETRIES = 3; @@ -1166,12 +1162,13 @@ const stepHandler = createStepHandler(); * for each step, this is temporary. */ let cachedStepHandler: ((req: Request) => Promise) | undefined; -let cachedWorldGeneration = -1; +let cachedWorld: World | undefined; export const stepEntrypoint: (req: Request) => Promise = /* @__PURE__ */ withHealthCheck(async (req) => { - if (!cachedStepHandler || cachedWorldGeneration !== getWorldGeneration()) { - cachedStepHandler = stepHandler(await getWorldHandlers()); - cachedWorldGeneration = getWorldGeneration(); + const world = await getWorld(); + if (!cachedStepHandler || cachedWorld !== world) { + cachedStepHandler = stepHandler(world); + cachedWorld = world; } return cachedStepHandler(req); }); diff --git a/packages/core/src/runtime/world-config.test.ts b/packages/core/src/runtime/world-config.test.ts index 9a810953ce..209f36be90 100644 --- a/packages/core/src/runtime/world-config.test.ts +++ b/packages/core/src/runtime/world-config.test.ts @@ -160,6 +160,7 @@ describe('configured World', () => { await getWorld(); const close = closeWorld(); const replacement = getWorld(); + const replacementAfterConcurrentClose = closeWorld().then(() => getWorld()); expect(() => setWorld(second)).toThrow( 'Cannot replace a World while it is closing.' ); @@ -168,6 +169,7 @@ describe('configured World', () => { finishClose(); await close; await expect(replacement).resolves.toBe(second); + await expect(replacementAfterConcurrentClose).resolves.toBe(second); expect(create).toHaveBeenCalledTimes(2); }); diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index b2a08dea26..aec5f20d36 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -26,30 +26,18 @@ function getRuntimeRequire() { const WorldCache = Symbol.for('@workflow/world//cache'); const WorldCachePromise = Symbol.for('@workflow/world//cachePromise'); -const ManagedWorldCache = Symbol.for('@workflow/world//managedCache'); -const ManagedWorldCachePromise = Symbol.for( - '@workflow/world//managedCachePromise' -); -const WorldClosePromise = Symbol.for('@workflow/world//closePromise'); -const WorldGeneration = Symbol.for('@workflow/world//generation'); +const WorldLifecycleKey = Symbol.for('@workflow/world//lifecycle'); + +type WorldLifecycle = + | { status: 'managed' } + | { status: 'closing'; managed: boolean; promise: Promise }; const globalSymbols: typeof globalThis & { [WorldCache]?: World; [WorldCachePromise]?: Promise; - [ManagedWorldCache]?: boolean; - [ManagedWorldCachePromise]?: boolean; - [WorldClosePromise]?: Promise; - [WorldGeneration]?: number; + [WorldLifecycleKey]?: WorldLifecycle; } = globalThis; -export function getWorldGeneration(): number { - return globalSymbols[WorldGeneration] ?? 0; -} - -function advanceWorldGeneration(): void { - globalSymbols[WorldGeneration] = getWorldGeneration() + 1; -} - function getWorkflowConfig() { return boundWorkflowConfig ?? getRuntimeWorkflowConfig() ?? {}; } @@ -200,18 +188,12 @@ export type WorldHandlers = Pick; * 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(); - return { - createQueueHandler: world.createQueueHandler, - specVersion: world.specVersion, - }; -}; +export const getWorldHandlers = (): Promise => getWorld(); export const getWorld = async (): Promise => { - const closingWorld = globalSymbols[WorldClosePromise]; - if (closingWorld) { - await closingWorld; + const lifecycle = globalSymbols[WorldLifecycleKey]; + if (lifecycle?.status === 'closing') { + await lifecycle.promise; } if (globalSymbols[WorldCache]) { @@ -220,8 +202,11 @@ export const getWorld = async (): Promise => { let pendingWorld = globalSymbols[WorldCachePromise]; if (!pendingWorld) { - globalSymbols[ManagedWorldCachePromise] = + const managed = !process.env.WORKFLOW_TARGET_WORLD && !!getWorkflowConfig().world; + globalSymbols[WorldLifecycleKey] = managed + ? { status: 'managed' } + : undefined; pendingWorld = resolveWorld().then(async (resolved) => { switch (resolved.type) { case 'configured': @@ -241,23 +226,20 @@ export const getWorld = async (): Promise => { }); globalSymbols[WorldCachePromise] = pendingWorld; } - const pendingWorldIsManaged = - globalSymbols[ManagedWorldCachePromise] ?? false; try { const world = await pendingWorld; if (globalSymbols[WorldCachePromise] === pendingWorld) { globalSymbols[WorldCache] = world; - globalSymbols[ManagedWorldCache] = pendingWorldIsManaged; globalSymbols[WorldCachePromise] = undefined; - globalSymbols[ManagedWorldCachePromise] = undefined; - advanceWorldGeneration(); } return world; } catch (error) { if (globalSymbols[WorldCachePromise] === pendingWorld) { globalSymbols[WorldCachePromise] = undefined; - globalSymbols[ManagedWorldCachePromise] = undefined; + if (globalSymbols[WorldLifecycleKey]?.status === 'managed') { + globalSymbols[WorldLifecycleKey] = undefined; + } } throw error; } @@ -265,54 +247,57 @@ export const getWorld = async (): Promise => { /** Override or clear an unmanaged cached World. */ export const setWorld = (world: World | undefined): void => { + const lifecycle = globalSymbols[WorldLifecycleKey]; assert( - !globalSymbols[WorldClosePromise], + lifecycle?.status !== 'closing', 'Cannot replace a World while it is closing.' ); assert( - !globalSymbols[ManagedWorldCache] && - !globalSymbols[ManagedWorldCachePromise], + lifecycle?.status !== 'managed', 'Call await closeWorld() before replacing a managed World.' ); globalSymbols[WorldCache] = world; globalSymbols[WorldCachePromise] = undefined; - globalSymbols[ManagedWorldCache] = undefined; - globalSymbols[ManagedWorldCachePromise] = undefined; - advanceWorldGeneration(); + globalSymbols[WorldLifecycleKey] = undefined; }; /** * Close the cached World without creating one just for cleanup. */ export const closeWorld = async (): Promise => { - if (globalSymbols[WorldClosePromise]) { - return globalSymbols[WorldClosePromise]; - } + const lifecycle = globalSymbols[WorldLifecycleKey]; + if (lifecycle?.status === 'closing') return lifecycle.promise; const cachedWorld = globalSymbols[WorldCache]; - const pendingWorld = globalSymbols[WorldCachePromise]; - const closePromise = (async () => { - const world = - cachedWorld ?? (pendingWorld ? await pendingWorld : undefined); - await world?.close?.(); - })(); - globalSymbols[WorldClosePromise] = closePromise; + const worldPromise = cachedWorld + ? Promise.resolve(cachedWorld) + : globalSymbols[WorldCachePromise]; + if (!worldPromise) return; - try { - await closePromise; - if (globalSymbols[WorldClosePromise] === closePromise) { + const managed = lifecycle?.status === 'managed'; + const closePromise = (async () => { + try { + const world = await worldPromise; + await world.close?.(); globalSymbols[WorldCache] = undefined; globalSymbols[WorldCachePromise] = undefined; - globalSymbols[ManagedWorldCache] = undefined; - globalSymbols[ManagedWorldCachePromise] = undefined; - advanceWorldGeneration(); + globalSymbols[WorldLifecycleKey] = undefined; + } catch (error) { + globalSymbols[WorldLifecycleKey] = + managed && + (globalSymbols[WorldCache] || globalSymbols[WorldCachePromise]) + ? { status: 'managed' } + : undefined; + throw error; } - } finally { - if (globalSymbols[WorldClosePromise] === closePromise) { - globalSymbols[WorldClosePromise] = undefined; - } - } + })(); + globalSymbols[WorldLifecycleKey] = { + status: 'closing', + managed, + promise: closePromise, + }; + return closePromise; }; // Register getWorld on globalThis so getWorldLazy can call it directly when diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index da8a16cbe3..31501731e3 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -1,5 +1,5 @@ import { constants } from 'node:fs'; -import { access, copyFile, mkdir, stat, writeFile } from 'node:fs/promises'; +import { access, copyFile, mkdir, rm, stat, writeFile } from 'node:fs/promises'; import { extname, join, relative, resolve } from 'node:path'; import type { NextConfig as BuilderNextConfig, @@ -70,11 +70,12 @@ export async function getNextBuilderEager() { }); // Expose manifest as a static file when WORKFLOW_PUBLIC_MANIFEST=1. + const publicManifestDir = join( + this.config.workingDir, + 'public/.well-known/workflow/v1' + ); + await rm(join(publicManifestDir, 'manifest.json'), { force: true }); if (this.shouldExposePublicManifest && manifestJson) { - const publicManifestDir = join( - this.config.workingDir, - 'public/.well-known/workflow/v1' - ); await mkdir(publicManifestDir, { recursive: true }); if (process.env.VERCEL_DEPLOYMENT_ID === undefined) { await writeFile(join(publicManifestDir, '.gitignore'), '*'); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index d4a04069fe..c1149d546c 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -378,15 +378,10 @@ export function withWorkflow( if (!process.env.VERCEL_DEPLOYMENT_ID) { process.env.WORKFLOW_LOCAL_DATA_DIR ??= '.next/workflow-data'; - if (!workflowConfig.world && !process.env.WORKFLOW_TARGET_WORLD) { - process.env.WORKFLOW_TARGET_WORLD = 'local'; - } 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 (!workflowConfig.world && !process.env.WORKFLOW_TARGET_WORLD) { - process.env.WORKFLOW_TARGET_WORLD = 'vercel'; } let nextConfig = @@ -524,19 +519,12 @@ export function withWorkflow( 'jsx', 'js', ], - projectRoot: - nextConfig.outputFileTracingRoot ?? - (workflowConfig.build?.projectRoot - ? resolve(process.cwd(), workflowConfig.build.projectRoot) - : undefined), + projectRoot: nextConfig.outputFileTracingRoot, moduleSpecifierRoot: process.cwd(), workingDir: process.cwd(), distDir, diagnosticsDir: `${distDir}/diagnostics`, buildTarget: 'next', - workflowsBundlePath: '', // not used in base - stepsBundlePath: '', // not used in base - webhookBundlePath: '', // node used in base sourcemap: workflows?.sourcemap, workflowConfig: loadedWorkflowConfig, externalPackages: [ diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index 74e985dcae..951b3b40a1 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -6,7 +6,7 @@ import { } from '@workflow/builders'; import type { LoadedWorkflowConfig } from '@workflow/config/load'; import type { Nitro } from 'nitro/types'; -import { join, resolve } from 'pathe'; +import { join } from 'pathe'; type NitroV2ExternalsOptions = { externals?: { external?: unknown[] } }; @@ -30,12 +30,8 @@ function createNitroBuilderConfig( 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, + externalPackages: externalPackages.length ? externalPackages : undefined, workflowConfig: loadedConfig, }); } diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts index d8b0811c56..b247eb2f5f 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -147,8 +147,10 @@ describe('@workflow/nitro virtual handlers', () => { expect(source).not.toContain('@workflow/config'); }); - it('installs runtime config as a Nitro plugin', async () => { - const project = createProject('export default {};'); + it('installs configured runtime values as a Nitro plugin', async () => { + const project = createProject( + `export default { queue: { namespace: 'app' } };` + ); const nitro = createNitroStub({ routing: false, dev: true, diff --git a/packages/sveltekit/src/builder.ts b/packages/sveltekit/src/builder.ts index 7b5d556479..c45b2f72bc 100644 --- a/packages/sveltekit/src/builder.ts +++ b/packages/sveltekit/src/builder.ts @@ -32,20 +32,11 @@ export class SvelteKitBuilder extends BaseBuilder { dirs: config.dirs ?? build?.dirs ?? ['workflows', 'src/workflows', 'routes', 'src/routes'], buildTarget: 'sveltekit' as const, - stepsBundlePath: '', // unused in base - workflowsBundlePath: '', // unused in base - webhookBundlePath: '', // unused in base workingDir, - projectRoot: - config.projectRoot ?? - (build?.projectRoot - ? resolve(workingDir, build.projectRoot) - : undefined), externalPackages: [ ...SVELTEKIT_VIRTUAL_MODULES, ...(config.externalPackages ?? build?.externalPackages ?? []), ], - sourcemap: config.sourcemap, }); } @@ -113,11 +104,12 @@ export const POST = async ({request}) => { // Expose manifest as a static file when WORKFLOW_PUBLIC_MANIFEST=1. // SvelteKit serves files from static/ at the root URL. + const staticManifestDir = join( + this.config.workingDir, + 'static/.well-known/workflow/v1' + ); + await rm(join(staticManifestDir, 'manifest.json'), { force: true }); if (this.shouldExposePublicManifest && manifestJson) { - const staticManifestDir = join( - this.config.workingDir, - 'static/.well-known/workflow/v1' - ); await mkdir(staticManifestDir, { recursive: true }); if (process.env.VERCEL_DEPLOYMENT_ID === undefined) { await writeFile(join(staticManifestDir, '.gitignore'), '*'); diff --git a/packages/sveltekit/src/plugin.ts b/packages/sveltekit/src/plugin.ts index 11b47ca32f..5efeb8d359 100644 --- a/packages/sveltekit/src/plugin.ts +++ b/packages/sveltekit/src/plugin.ts @@ -1,4 +1,5 @@ import { createBuildQueue } from '@workflow/builders'; +import type { SourcemapMode } from '@workflow/config'; import { loadWorkflowConfig } from '@workflow/config/load'; import { workflowTransformPlugin } from '@workflow/rollup'; import { workflowHotUpdatePlugin } from '@workflow/vite'; @@ -7,6 +8,7 @@ import { SvelteKitBuilder } from './builder.js'; export const loadedWorkflowConfig = await loadWorkflowConfig({ cwd: process.cwd(), + integration: 'sveltekit', }); export interface WorkflowPluginOptions { @@ -16,7 +18,7 @@ export interface WorkflowPluginOptions { * `'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; } export function workflowPlugin(options: WorkflowPluginOptions = {}): Plugin[] { diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts index f028dccc3d..39f57d4030 100644 --- a/packages/world-local/src/index.ts +++ b/packages/world-local/src/index.ts @@ -2,7 +2,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 { + getQueueTopicPrefix, + reenqueueActiveRuns, + resolveQueueNamespace, + SPEC_VERSION_CURRENT, +} from '@workflow/world'; import type { Config } from './config.js'; import { config } from './config.js'; import { @@ -88,7 +93,11 @@ export function createLocalWorld(args?: Partial): LocalWorld { })) as typeof storage.runs.list, } : storage.runs; - await reenqueueActiveRuns(recoveryRuns, queue.queue, 'world-local'); + await reenqueueActiveRuns( + recoveryRuns, + queue.queue, + getQueueTopicPrefix('workflow', resolveQueueNamespace()) + ); }, async close() { await queue.close(); diff --git a/packages/world-local/src/reenqueue.test.ts b/packages/world-local/src/reenqueue.test.ts index 2759f1966c..d6c1a95fea 100644 --- a/packages/world-local/src/reenqueue.test.ts +++ b/packages/world-local/src/reenqueue.test.ts @@ -1,8 +1,8 @@ +import { rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { rm } from 'node:fs/promises'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createLocalWorld, type LocalWorld } from './index.js'; +import { createLocalWorld } from './index.js'; import { createRun, updateRun } from './test-helpers.js'; // Mock node:timers/promises so the queue's setTimeout resolves immediately @@ -18,6 +18,7 @@ describe('re-enqueue active runs on start', () => { }); afterEach(async () => { + delete process.env.WORKFLOW_QUEUE_NAMESPACE; await rm(dataDir, { recursive: true, force: true }); }); @@ -121,6 +122,29 @@ describe('re-enqueue active runs on start', () => { await world2.close(); }); + it('uses the active queue namespace', async () => { + const world1 = createLocalWorld({ dataDir }); + await world1.start(); + const run = await createRun(world1, { + deploymentId: 'dpl_1', + workflowName: 'myWorkflow', + input: new Uint8Array([1]), + }); + await world1.close(); + + process.env.WORKFLOW_QUEUE_NAMESPACE = 'myapp'; + const world2 = createLocalWorld({ dataDir }); + const receivedRunIds: string[] = []; + world2.registerHandler('__myapp_wkf_workflow_', async (req) => { + receivedRunIds.push((await req.json()).runId); + return Response.json({ ok: true }); + }); + + await world2.start(); + await vi.waitFor(() => expect(receivedRunIds).toEqual([run.runId])); + await world2.close(); + }); + it('only re-enqueues runs for the matching tag', async () => { const world0 = createLocalWorld({ dataDir, tag: 'vitest-0' }); await world0.start(); diff --git a/packages/world-postgres/HOW_IT_WORKS.md b/packages/world-postgres/HOW_IT_WORKS.md index 4eb60af1a0..e6e4069a98 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 diff --git a/packages/world-postgres/src/index.ts b/packages/world-postgres/src/index.ts index 9ad7565e06..2e980a7731 100644 --- a/packages/world-postgres/src/index.ts +++ b/packages/world-postgres/src/index.ts @@ -1,5 +1,10 @@ import type { Storage, World } from '@workflow/world'; -import { reenqueueActiveRuns, SPEC_VERSION_CURRENT } from '@workflow/world'; +import { + getQueueTopicPrefix, + reenqueueActiveRuns, + resolveQueueNamespace, + SPEC_VERSION_CURRENT, +} from '@workflow/world'; import { Pool } from 'pg'; import type { PostgresWorldConfig } from './config.js'; import { createClient, type Drizzle } from './drizzle/index.js'; @@ -66,7 +71,11 @@ export function createWorld( }), async start() { await queue.start(); - await reenqueueActiveRuns(storage.runs, queue.queue, 'world-postgres'); + await reenqueueActiveRuns( + storage.runs, + queue.queue, + getQueueTopicPrefix('workflow', resolveQueueNamespace(config.namespace)) + ); }, async close() { await streamer.close(); diff --git a/packages/world/src/recovery.ts b/packages/world/src/recovery.ts index d20cfa33c1..43e015ca01 100644 --- a/packages/world/src/recovery.ts +++ b/packages/world/src/recovery.ts @@ -1,6 +1,5 @@ -import type { Queue } from './queue.js'; import type { Storage } from './interfaces.js'; -import type { ValidQueueName } from './queue.js'; +import type { Queue, QueuePrefix, ValidQueueName } from './queue.js'; /** * Re-enqueue all active (pending/running) workflow runs so they resume @@ -9,12 +8,12 @@ import type { ValidQueueName } from './queue.js'; * * @param runs - Storage runs interface for listing active runs * @param enqueue - Queue's enqueue method - * @param label - Log prefix for identifying the world implementation (e.g. "world-local") + * @param workflowPrefix - Active workflow queue prefix */ export async function reenqueueActiveRuns( runs: Storage['runs'], enqueue: Queue['queue'], - label: string + workflowPrefix: QueuePrefix ): Promise { let reenqueued = 0; for (const status of ['pending', 'running'] as const) { @@ -28,12 +27,13 @@ export async function reenqueueActiveRuns( }); for (const run of page.data) { try { - const queueName: ValidQueueName = `__wkf_workflow_${run.workflowName}`; + const queueName = + `${workflowPrefix}${run.workflowName}` as ValidQueueName; await enqueue(queueName, { runId: run.runId }); reenqueued++; } catch (err) { console.warn( - `[${label}] Failed to re-enqueue run ${run.runId}: ${err}` + `[workflow] Failed to re-enqueue run ${run.runId}: ${err}` ); } } @@ -43,7 +43,7 @@ export async function reenqueueActiveRuns( } if (reenqueued > 0) { console.log( - `[${label}] Re-enqueued ${reenqueued} active run(s) on startup` + `[workflow] Re-enqueued ${reenqueued} active run(s) on startup` ); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35155979d9..b1a83f0308 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -424,9 +424,6 @@ 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 From 415cff8fd0a789a8ef873d80e3e2b051266c8485 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:34:21 -0700 Subject: [PATCH 24/28] Fix config integration fallbacks Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/astro/src/plugin.ts | 42 ++++++++++--------- packages/builders/src/base-builder.ts | 1 + packages/builders/src/constants.test.ts | 6 +-- packages/builders/src/constants.ts | 7 ++-- packages/config/src/load.test.ts | 14 +++++++ packages/config/src/load.ts | 8 +++- .../server/workflow-server-actions.server.ts | 31 ++------------ 7 files changed, 55 insertions(+), 54 deletions(-) diff --git a/packages/astro/src/plugin.ts b/packages/astro/src/plugin.ts index 07fd8f5365..ad8bfcbf8b 100644 --- a/packages/astro/src/plugin.ts +++ b/packages/astro/src/plugin.ts @@ -1,6 +1,10 @@ +import { fileURLToPath } from 'node:url'; import { createBuildQueue } from '@workflow/builders'; import type { SourcemapMode } from '@workflow/config'; -import { loadWorkflowConfig } from '@workflow/config/load'; +import { + type LoadedWorkflowConfig, + loadWorkflowConfig, +} from '@workflow/config/load'; import { workflowTransformPlugin } from '@workflow/rollup'; import { workflowHotUpdatePlugin } from '@workflow/vite'; import type { AstroIntegration, HookParameters } from 'astro'; @@ -19,10 +23,10 @@ export interface WorkflowPluginOptions { export function workflowPlugin( options: WorkflowPluginOptions = {} ): AstroIntegration { - const workflowConfig = loadWorkflowConfig({ - cwd: process.cwd(), - integration: 'astro', - }); + let builderConfig: { + workingDir: string; + workflowConfig: LoadedWorkflowConfig; + }; let builder: LocalBuilder; const enqueue = createBuildQueue(); @@ -30,27 +34,27 @@ export function workflowPlugin( name: 'workflow:astro', hooks: { 'astro:config:setup': async ({ + config, updateConfig, }: HookParameters<'astro:config:setup'>) => { - const loadedConfig = await workflowConfig; + const workingDir = fileURLToPath(config.root); + builderConfig = { + workingDir, + workflowConfig: await loadWorkflowConfig({ + cwd: workingDir, + integration: 'astro', + }), + }; builder = new LocalBuilder({ + ...builderConfig, sourcemap: options.sourcemap, - workflowConfig: loadedConfig, }); - // Use local builder if (!process.env.VERCEL_DEPLOYMENT_ID) { - try { - await builder.build(); - } catch (buildError) { - // Build might fail due to invalid workflow files or missing dependencies - // Log the error and rethrow to properly propagate to Astro - console.error('Build failed during config setup:', buildError); - throw buildError; - } + await builder.build(); } updateConfig({ vite: { - ...(loadedConfig.runtimePath + ...(builderConfig.workflowConfig.runtimePath ? { ssr: { noExternal: ['workflow', '@workflow/core'] } } : {}), plugins: [ @@ -60,7 +64,7 @@ export function workflowPlugin( enforce: 'pre', resolveId(source) { if (source === '@workflow/config/runtime-binding') { - return loadedConfig.runtimePath; + return builderConfig.workflowConfig.runtimePath; } }, }, @@ -76,8 +80,8 @@ export function workflowPlugin( 'astro:build:done': async () => { if (process.env.VERCEL_DEPLOYMENT_ID) { const vercelBuilder = new VercelBuilder({ + ...builderConfig, sourcemap: options.sourcemap, - workflowConfig: await workflowConfig, }); await vercelBuilder.build(); } diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 11ceeaa470..a738f2a9d3 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -22,6 +22,7 @@ import { applySwcTransform, type WorkflowManifest, } from './apply-swc-transform.js'; +import { createWorkflowEntrypointOptionsCode } from './constants.js'; import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js'; import { type DiscoveredEntries, diff --git a/packages/builders/src/constants.test.ts b/packages/builders/src/constants.test.ts index 3e086100f9..c92ee69aab 100644 --- a/packages/builders/src/constants.test.ts +++ b/packages/builders/src/constants.test.ts @@ -37,7 +37,7 @@ describe('createWorkflowEntrypointOptionsCode', () => { it('inlines an explicit namespace', () => { expect(createWorkflowEntrypointOptionsCode({ namespace: 'custom' })).toBe( - ', { namespace: "custom" }' + ', { namespace: process.env.WORKFLOW_QUEUE_NAMESPACE ?? "custom" }' ); }); @@ -45,7 +45,7 @@ describe('createWorkflowEntrypointOptionsCode', () => { process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom'; expect(createWorkflowEntrypointOptionsCode()).toBe( - ', { namespace: "custom" }' + ', { namespace: process.env.WORKFLOW_QUEUE_NAMESPACE ?? "custom" }' ); }); @@ -56,7 +56,7 @@ describe('createWorkflowEntrypointOptionsCode', () => { routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt', }) ).toBe( - ', { namespace: "custom", routeModuleBodyStartedAt: workflowRouteModuleBodyStartedAt }' + ', { namespace: process.env.WORKFLOW_QUEUE_NAMESPACE ?? "custom", routeModuleBodyStartedAt: workflowRouteModuleBodyStartedAt }' ); }); }); diff --git a/packages/builders/src/constants.ts b/packages/builders/src/constants.ts index 7d063890e8..3391b3099a 100644 --- a/packages/builders/src/constants.ts +++ b/packages/builders/src/constants.ts @@ -49,8 +49,7 @@ export function createWorkflowQueueTrigger(options?: { namespace?: string }) { /** * Creates the optional second argument for generated `workflowEntrypoint()` - * calls. The namespace is resolved while building so generated route files do - * not need `WORKFLOW_QUEUE_NAMESPACE` at runtime. + * calls. Runtime environment variables override the build-time fallback. */ export function createWorkflowEntrypointOptionsCode(options?: { namespace?: string; @@ -63,7 +62,9 @@ export function createWorkflowEntrypointOptionsCode(options?: { if (namespace) { // Reuse prefix construction for namespace validation. getQueueTopicPrefix('workflow', namespace); - fields.push(`namespace: ${JSON.stringify(namespace)}`); + fields.push( + `namespace: process.env.WORKFLOW_QUEUE_NAMESPACE ?? ${JSON.stringify(namespace)}` + ); } if (options?.routeModuleBodyStartedAt) { diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index f7385853b6..032d7b27c3 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -69,6 +69,20 @@ describe('loadWorkflowConfig', () => { expect(getRuntimeWorkflowConfig()).toBe(runtime.default); }); + it('preserves a build-time queue namespace in the runtime binding', async () => { + process.env.WORKFLOW_QUEUE_NAMESPACE = 'environment'; + const project = createProject({ + 'workflow.config.ts': `export default { build: { dirs: ['jobs'] } };`, + }); + + const loaded = await loadWorkflowConfig({ cwd: project }); + const runtime = (await import( + pathToFileURL(loaded.runtimePath as string).href + )) as { default: RuntimeWorkflowConfig }; + + expect(runtime.default.queue).toEqual({ namespace: 'environment' }); + }); + it('loads the nearest TypeScript config without merging parents', async () => { const project = createProject({ 'workflow.config.ts': `export default { build: { dirs: ['parent'] } };`, diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 52abc373a1..2db846a664 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -118,7 +118,11 @@ export async function loadWorkflowConfig( `${basename(path)} configures "${config.integration?.type}" but was loaded by "${options.integration}".` ); - if (!config.world && !config.queue) { + const namespace = + process.env.WORKFLOW_QUEUE_NAMESPACE ?? config.queue?.namespace; + const queue = namespace === undefined ? undefined : { namespace }; + + if (!config.world && !queue) { return { path, runtimePath: undefined, config }; } @@ -150,7 +154,7 @@ export async function loadWorkflowConfig( mkdirSync(runtimeDir, { recursive: true }); writeFileSync( runtimePath, - `const world = ${worldFactory};\nconst config = { world, queue: ${JSON.stringify(config.queue)} };\nglobalThis[Symbol.for('@workflow/config/runtime')] = config;\nexport default config;\n` + `const world = ${worldFactory};\nconst config = { world, queue: ${JSON.stringify(queue)} };\nglobalThis[Symbol.for('@workflow/config/runtime')] = config;\nexport default config;\n` ); return { path, runtimePath, config }; diff --git a/packages/web/app/server/workflow-server-actions.server.ts b/packages/web/app/server/workflow-server-actions.server.ts index 3bdfe975fb..a1e072948c 100644 --- a/packages/web/app/server/workflow-server-actions.server.ts +++ b/packages/web/app/server/workflow-server-actions.server.ts @@ -8,7 +8,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import * as workflowRunHelpers from '@workflow/core/runtime'; -import { createWorld } from '@workflow/core/runtime'; +import { getWorld } from '@workflow/core/runtime'; import { type HealthCheckEndpoint, type HealthCheckResult, @@ -16,7 +16,7 @@ import { } from '@workflow/core/runtime/helpers'; import { resumeHook as resumeHookRuntime } from '@workflow/core/runtime/resume-hook'; -import { WorkflowWorldError, WorkflowRunNotFoundError } from '@workflow/errors'; +import { WorkflowRunNotFoundError, WorkflowWorldError } from '@workflow/errors'; import { findWorkflowDataDir } from '@workflow/utils/check-data-dir'; import type { Event, @@ -26,7 +26,7 @@ import type { WorkflowRunStatus, World, } from '@workflow/world'; -import { type APIConfig, createVercelWorld } from '@workflow/world-vercel'; +import { createVercelWorld } from '@workflow/world-vercel'; /** * Environment variable map for world configuration. @@ -383,15 +383,6 @@ export type ServerActionResult = | { success: true; data: T } | { success: false; error: ServerActionError }; -/** - * Cache for World instances. - * - * IMPORTANT: - * - We only cache non-vercel worlds. - * - Cache keys are derived from **server-side** WORKFLOW_* env vars only. - */ -const worldCache = new Map(); - /** * Get or create a World instance based on configuration. * @@ -435,21 +426,7 @@ async function getWorldFromEnv(userEnvMap: EnvMap): Promise { await ensureLocalWorldDataDirEnv(); } - // Cache key derived ONLY from WORKFLOW_* env vars. - const workflowEnvEntries = Object.entries(process.env).filter(([key]) => - key.startsWith('WORKFLOW_') - ); - workflowEnvEntries.sort(([a], [b]) => a.localeCompare(b)); - const cacheKey = JSON.stringify(Object.fromEntries(workflowEnvEntries)); - - const cachedWorld = worldCache.get(cacheKey); - if (cachedWorld) { - return cachedWorld; - } - - const world = await createWorld(); - worldCache.set(cacheKey, world); - return world; + return getWorld(); } /** From e7170de803f332585aefd482ef65b29ffabb2801 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:47:45 -0700 Subject: [PATCH 25/28] Preserve configless queue namespace Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .changeset/shared-config-runtime.md | 1 + packages/cli/src/lib/inspect/setup.ts | 4 +- packages/config/src/load.test.ts | 8 +-- packages/config/src/load.ts | 99 +++++++++++++++------------ 4 files changed, 62 insertions(+), 50 deletions(-) diff --git a/.changeset/shared-config-runtime.md b/.changeset/shared-config-runtime.md index d9ea52ef75..d763e8fdb4 100644 --- a/.changeset/shared-config-runtime.md +++ b/.changeset/shared-config-runtime.md @@ -2,6 +2,7 @@ "@workflow/builders": minor "@workflow/cli": minor "@workflow/core": minor +"@workflow/web": patch "workflow": minor --- diff --git a/packages/cli/src/lib/inspect/setup.ts b/packages/cli/src/lib/inspect/setup.ts index ae0a21fa6d..fc35562136 100644 --- a/packages/cli/src/lib/inspect/setup.ts +++ b/packages/cli/src/lib/inspect/setup.ts @@ -34,9 +34,7 @@ export const setupCliWorld = async ( setVerboseMode(Boolean(flags.verbose)); const loadedConfig = await loadProjectWorkflowConfig(); - setRuntimeWorkflowConfig( - loadedConfig.path ? createRuntimeWorkflowConfig(loadedConfig) : undefined - ); + setRuntimeWorkflowConfig(createRuntimeWorkflowConfig(loadedConfig)); const backend = flags.backend ?? diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index 032d7b27c3..1fe3785acd 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -69,17 +69,17 @@ describe('loadWorkflowConfig', () => { expect(getRuntimeWorkflowConfig()).toBe(runtime.default); }); - it('preserves a build-time queue namespace in the runtime binding', async () => { + it('preserves a build-time queue namespace without a config file', async () => { process.env.WORKFLOW_QUEUE_NAMESPACE = 'environment'; - const project = createProject({ - 'workflow.config.ts': `export default { build: { dirs: ['jobs'] } };`, - }); + const project = createProject({}); const loaded = await loadWorkflowConfig({ cwd: project }); const runtime = (await import( pathToFileURL(loaded.runtimePath as string).href )) as { default: RuntimeWorkflowConfig }; + expect(loaded.path).toBeUndefined(); + expect(loaded.config.queue).toEqual({ namespace: 'environment' }); expect(runtime.default.queue).toEqual({ namespace: 'environment' }); }); diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 2db846a664..191cd8b223 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -38,15 +38,11 @@ export type LoadWorkflowConfigOptions = { integration?: WorkflowIntegrationType; }; -export type LoadedWorkflowConfig = - | { path: undefined; runtimePath: undefined; config: WorkflowConfig } - | { - path: string; - runtimePath: string | undefined; - config: WorkflowConfig; - }; - -type FoundWorkflowConfig = Extract; +export type LoadedWorkflowConfig = { + path: string | undefined; + runtimePath: string | undefined; + config: WorkflowConfig; +}; async function discoverWorkflowConfig({ cwd, @@ -90,14 +86,10 @@ async function discoverWorkflowConfig({ ); } -export async function loadWorkflowConfig( - options: LoadWorkflowConfigOptions -): Promise { - const path = await discoverWorkflowConfig(options); - if (!path) { - return { path: undefined, runtimePath: undefined, config: {} }; - } - +async function readWorkflowConfig( + path: string, + integration: WorkflowIntegrationType | undefined +): Promise { const configModule = await createJiti(import.meta.url, { interopDefault: false, }).import<{ default: unknown }>(path); @@ -111,40 +103,60 @@ export async function loadWorkflowConfig( ); const config = WorkflowConfigSchema.parse(rawConfig); + if (!integration || !config.integration) return config; + assert( - !options.integration || - !config.integration || - config.integration.type === options.integration, - `${basename(path)} configures "${config.integration?.type}" but was loaded by "${options.integration}".` + config.integration.type === integration, + `${basename(path)} configures "${config.integration.type}" but was loaded by "${integration}".` ); + return config; +} - const namespace = - process.env.WORKFLOW_QUEUE_NAMESPACE ?? config.queue?.namespace; - const queue = namespace === undefined ? undefined : { namespace }; +export async function loadWorkflowConfig( + options: LoadWorkflowConfigOptions +): Promise { + const path = await discoverWorkflowConfig(options); + let config: WorkflowConfig = path + ? await readWorkflowConfig(path, options.integration) + : {}; + + if (process.env.WORKFLOW_QUEUE_NAMESPACE !== undefined) { + config = WorkflowConfigSchema.parse({ + ...config, + queue: { namespace: process.env.WORKFLOW_QUEUE_NAMESPACE }, + }); + } - if (!config.world && !queue) { + if (!config.world && !config.queue) { return { path, runtimePath: undefined, config }; } - const runtimeDir = join(dirname(path), 'node_modules', '.cache', 'workflow'); - let world = config.world; - assert( - !world || - (!isAbsolute(world) && - !win32.isAbsolute(world) && - !/^[a-z][a-z\d+.-]*:/i.test(world)), - `World module must be a relative path or package specifier: ${world}` + const runtimeDir = join( + path ? dirname(path) : options.cwd, + 'node_modules', + '.cache', + 'workflow' ); - if (world?.startsWith('.')) { - const worldPath = resolve(dirname(path), world); + let world = config.world; + if (world) { + assert(path); assert( - existsSync(worldPath) && statSync(worldPath).isFile(), - `World module not found: ${world}` + !isAbsolute(world) && + !win32.isAbsolute(world) && + !/^[a-z][a-z\d+.-]*:/i.test(world), + `World module must be a relative path or package specifier: ${world}` ); - world = relative(runtimeDir, worldPath).replaceAll('\\', '/'); - if (!world.startsWith('.')) world = `./${world}`; - } else if (world) { - createJiti(path).esmResolve(world); + if (world.startsWith('.')) { + 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 { + createJiti(path).esmResolve(world); + } } const runtimePath = join(runtimeDir, 'runtime-config.mjs'); @@ -154,7 +166,7 @@ export async function loadWorkflowConfig( mkdirSync(runtimeDir, { recursive: true }); writeFileSync( runtimePath, - `const world = ${worldFactory};\nconst config = { world, queue: ${JSON.stringify(queue)} };\nglobalThis[Symbol.for('@workflow/config/runtime')] = config;\nexport default config;\n` + `const world = ${worldFactory};\nconst config = { world, queue: ${JSON.stringify(config.queue)} };\nglobalThis[Symbol.for('@workflow/config/runtime')] = config;\nexport default config;\n` ); return { path, runtimePath, config }; @@ -163,9 +175,10 @@ export async function loadWorkflowConfig( export function createRuntimeWorkflowConfig({ path, config, -}: FoundWorkflowConfig): RuntimeWorkflowConfig { +}: LoadedWorkflowConfig): RuntimeWorkflowConfig { if (!config.world) return { queue: config.queue }; + assert(path); const world = config.world; const jiti = createJiti(path, { interopDefault: false }); return { From de7193d30f4c7bd1fe697879c143bccad5f2612a Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:07:45 -0700 Subject: [PATCH 26/28] Keep configured world lifecycle consistent Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/core/src/runtime.ts | 1 + .../core/src/runtime/world-config.test.ts | 38 ++++++++++- packages/core/src/runtime/world.ts | 65 +++++++++++++++---- .../workflow-server-actions.server.test.ts | 29 +++++++++ .../server/workflow-server-actions.server.ts | 5 ++ 5 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 packages/web/app/server/workflow-server-actions.server.test.ts diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index bde358d031..04a70cd01f 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -119,6 +119,7 @@ export { getWorld, getWorldHandlers, setWorld, + usesConfiguredWorld, } from './runtime/world.js'; function getWorkflowSetupErrorCode(err: unknown): RunErrorCode | null { diff --git a/packages/core/src/runtime/world-config.test.ts b/packages/core/src/runtime/world-config.test.ts index 209f36be90..a335fdd190 100644 --- a/packages/core/src/runtime/world-config.test.ts +++ b/packages/core/src/runtime/world-config.test.ts @@ -3,7 +3,13 @@ import type { World } from '@workflow/world'; 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, setWorld } from './world.js'; +import { + closeWorld, + getWorld, + getWorldHandlers, + setWorld, + usesConfiguredWorld, +} from './world.js'; const targetWorld = process.env.WORKFLOW_TARGET_WORLD; @@ -68,12 +74,42 @@ describe('configured World', () => { .mockReturnValueOnce(second); setRuntimeWorkflowConfig({ world: create }); + expect(usesConfiguredWorld()).toBe(true); await expect(getWorld()).rejects.toThrow('startup failed'); expect(firstClose).toHaveBeenCalledOnce(); await expect(getWorld()).resolves.toBe(second); expect(create).toHaveBeenCalledTimes(2); }); + it('requires failed startup cleanup before creating a replacement', async () => { + delete process.env.WORKFLOW_TARGET_WORLD; + const firstClose = vi + .fn() + .mockRejectedValueOnce(new Error('close failed')) + .mockResolvedValueOnce(undefined); + 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( + 'World startup and cleanup failed.' + ); + expect(create).toHaveBeenCalledOnce(); + await closeWorld(); + expect(firstClose).toHaveBeenCalledTimes(2); + await expect(getWorld()).resolves.toBe(second); + }); + it('rejects a configured provider that returns no World', async () => { delete process.env.WORKFLOW_TARGET_WORLD; setRuntimeWorkflowConfig({ world: () => undefined as never }); diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index aec5f20d36..1ecbe17255 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -30,6 +30,7 @@ const WorldLifecycleKey = Symbol.for('@workflow/world//lifecycle'); type WorldLifecycle = | { status: 'managed' } + | { status: 'cleanup'; world: World } | { status: 'closing'; managed: boolean; promise: Promise }; const globalSymbols: typeof globalThis & { @@ -42,6 +43,10 @@ function getWorkflowConfig() { return boundWorkflowConfig ?? getRuntimeWorkflowConfig() ?? {}; } +export function usesConfiguredWorld(): boolean { + return !process.env.WORKFLOW_TARGET_WORLD && !!getWorkflowConfig().world; +} + setWorkflowQueueNamespace(getWorkflowConfig().queue?.namespace); // Dynamic import for custom world modules. Uses a standard import() @@ -194,6 +199,8 @@ export const getWorld = async (): Promise => { const lifecycle = globalSymbols[WorldLifecycleKey]; if (lifecycle?.status === 'closing') { await lifecycle.promise; + } else if (lifecycle?.status === 'cleanup') { + await closeWorld(); } if (globalSymbols[WorldCache]) { @@ -212,9 +219,20 @@ export const getWorld = async (): Promise => { case 'configured': try { await resolved.world.start?.(); - } catch (error) { - await resolved.world.close?.(); - throw error; + } catch (startError) { + try { + await resolved.world.close?.(); + } catch (closeError) { + globalSymbols[WorldLifecycleKey] = { + status: 'cleanup', + world: resolved.world, + }; + throw new AggregateError( + [startError, closeError], + 'World startup and cleanup failed.' + ); + } + throw startError; } return resolved.world; case 'legacy': @@ -253,7 +271,7 @@ export const setWorld = (world: World | undefined): void => { 'Cannot replace a World while it is closing.' ); assert( - lifecycle?.status !== 'managed', + lifecycle?.status !== 'managed' && lifecycle?.status !== 'cleanup', 'Call await closeWorld() before replacing a managed World.' ); @@ -262,6 +280,24 @@ export const setWorld = (world: World | undefined): void => { globalSymbols[WorldLifecycleKey] = undefined; }; +function restoreLifecycleAfterCloseFailure( + cleanupWorld: World | undefined, + managed: boolean +): void { + if (cleanupWorld) { + globalSymbols[WorldLifecycleKey] = { + status: 'cleanup', + world: cleanupWorld, + }; + return; + } + + globalSymbols[WorldLifecycleKey] = + managed && (globalSymbols[WorldCache] || globalSymbols[WorldCachePromise]) + ? { status: 'managed' } + : undefined; +} + /** * Close the cached World without creating one just for cleanup. */ @@ -270,12 +306,17 @@ export const closeWorld = async (): Promise => { if (lifecycle?.status === 'closing') return lifecycle.promise; const cachedWorld = globalSymbols[WorldCache]; - const worldPromise = cachedWorld - ? Promise.resolve(cachedWorld) - : globalSymbols[WorldCachePromise]; + const cleanupWorld = + lifecycle?.status === 'cleanup' ? lifecycle.world : undefined; + const worldPromise = cleanupWorld + ? Promise.resolve(cleanupWorld) + : cachedWorld + ? Promise.resolve(cachedWorld) + : globalSymbols[WorldCachePromise]; if (!worldPromise) return; - const managed = lifecycle?.status === 'managed'; + const managed = + lifecycle?.status === 'managed' || lifecycle?.status === 'cleanup'; const closePromise = (async () => { try { const world = await worldPromise; @@ -284,11 +325,9 @@ export const closeWorld = async (): Promise => { globalSymbols[WorldCachePromise] = undefined; globalSymbols[WorldLifecycleKey] = undefined; } catch (error) { - globalSymbols[WorldLifecycleKey] = - managed && - (globalSymbols[WorldCache] || globalSymbols[WorldCachePromise]) - ? { status: 'managed' } - : undefined; + if (globalSymbols[WorldLifecycleKey]?.status === 'closing') { + restoreLifecycleAfterCloseFailure(cleanupWorld, managed); + } throw error; } })(); diff --git a/packages/web/app/server/workflow-server-actions.server.test.ts b/packages/web/app/server/workflow-server-actions.server.test.ts new file mode 100644 index 0000000000..5e31dec1d2 --- /dev/null +++ b/packages/web/app/server/workflow-server-actions.server.test.ts @@ -0,0 +1,29 @@ +import type { World } from '@workflow/world'; +import { afterEach, expect, it } from 'vitest'; +import { getPublicServerConfig } from './workflow-server-actions.server.js'; + +const runtimeConfig = Symbol.for('@workflow/config/runtime'); +const globals = globalThis as typeof globalThis & { + [key: symbol]: { world?: () => World } | undefined; +}; +const targetWorld = process.env.WORKFLOW_TARGET_WORLD; +const deploymentId = process.env.VERCEL_DEPLOYMENT_ID; + +afterEach(() => { + delete globals[runtimeConfig]; + if (targetWorld === undefined) delete process.env.WORKFLOW_TARGET_WORLD; + else process.env.WORKFLOW_TARGET_WORLD = targetWorld; + if (deploymentId === undefined) delete process.env.VERCEL_DEPLOYMENT_ID; + else process.env.VERCEL_DEPLOYMENT_ID = deploymentId; +}); + +it('identifies a configured World without treating it as local', async () => { + delete process.env.WORKFLOW_TARGET_WORLD; + delete process.env.VERCEL_DEPLOYMENT_ID; + globals[runtimeConfig] = { world: () => ({}) as World }; + + await expect(getPublicServerConfig()).resolves.toMatchObject({ + backendId: 'configured', + backendDisplayName: 'Configured', + }); +}); diff --git a/packages/web/app/server/workflow-server-actions.server.ts b/packages/web/app/server/workflow-server-actions.server.ts index a1e072948c..a93c796088 100644 --- a/packages/web/app/server/workflow-server-actions.server.ts +++ b/packages/web/app/server/workflow-server-actions.server.ts @@ -80,6 +80,8 @@ function getBackendDisplayName(targetWorld: string | undefined): string { return 'Local'; case 'vercel': return 'Vercel'; + case 'configured': + return 'Configured'; case '@workflow/world-postgres': case 'postgres': return 'PostgreSQL'; @@ -99,6 +101,9 @@ function getEffectiveBackendId(): string { if (targetWorld) { return targetWorld; } + if (workflowRunHelpers.usesConfiguredWorld()) { + return 'configured'; + } // Match @workflow/core/runtime defaulting: vercel if VERCEL_DEPLOYMENT_ID is set, else local. return process.env.VERCEL_DEPLOYMENT_ID ? 'vercel' : 'local'; } From b7e25535e7ec402d11011c7cd2dcfa1eae08fd21 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:45:52 -0700 Subject: [PATCH 27/28] Keep World providers lazy and namespaced Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/config/src/load.test.ts | 24 +++++++------------ packages/config/src/load.ts | 4 ++-- packages/config/src/runtime-binding.ts | 11 +-------- packages/config/src/runtime.ts | 7 +++++- packages/core/src/runtime.test.ts | 19 +++++++++++++++ packages/core/src/runtime.ts | 20 +++++++++------- .../core/src/runtime/step-handler.test.ts | 17 ++++++++++++- packages/core/src/runtime/step-handler.ts | 4 +--- packages/core/src/runtime/world.ts | 4 ++-- packages/nitro/src/index.test.ts | 15 ++++++++++++ packages/nitro/src/index.ts | 12 ++++------ packages/world-postgres/src/config.ts | 2 +- packages/world-postgres/src/index.ts | 11 ++++++++- packages/world-postgres/src/queue.test.ts | 15 +++++++----- packages/world-postgres/src/queue.ts | 2 +- packages/world-postgres/src/reenqueue.test.ts | 16 +++++-------- 16 files changed, 114 insertions(+), 69 deletions(-) diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index 1fe3785acd..0aa8d69ad8 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -14,7 +14,6 @@ import { getRuntimeWorkflowConfig, setRuntimeWorkflowConfig, } from './runtime.js'; -import type { RuntimeWorkflowConfig } from './runtime-binding.js'; import { WorkflowConfigSchema } from './schema.js'; const tempDirs: string[] = []; @@ -58,15 +57,12 @@ describe('loadWorkflowConfig', () => { 'workflow.config.ts': `export default { queue: { namespace: 'app' } };`, }); const loaded = await loadWorkflowConfig({ cwd: project }); - const runtime = (await import( - pathToFileURL(loaded.runtimePath as string).href - )) as { default: RuntimeWorkflowConfig }; + await import(pathToFileURL(loaded.runtimePath as string).href); - expect(runtime.default).toEqual({ + expect(getRuntimeWorkflowConfig()).toEqual({ world: undefined, queue: { namespace: 'app' }, }); - expect(getRuntimeWorkflowConfig()).toBe(runtime.default); }); it('preserves a build-time queue namespace without a config file', async () => { @@ -74,13 +70,13 @@ describe('loadWorkflowConfig', () => { const project = createProject({}); const loaded = await loadWorkflowConfig({ cwd: project }); - const runtime = (await import( - pathToFileURL(loaded.runtimePath as string).href - )) as { default: RuntimeWorkflowConfig }; + await import(pathToFileURL(loaded.runtimePath as string).href); expect(loaded.path).toBeUndefined(); expect(loaded.config.queue).toEqual({ namespace: 'environment' }); - expect(runtime.default.queue).toEqual({ namespace: 'environment' }); + expect(getRuntimeWorkflowConfig()?.queue).toEqual({ + namespace: 'environment', + }); }); it('loads the nearest TypeScript config without merging parents', async () => { @@ -138,14 +134,10 @@ export default () => ({}); }); const loaded = await loadWorkflowConfig({ cwd: project }); - const runtime = (await import( - pathToFileURL(loaded.runtimePath as string).href - )) as { - default: RuntimeWorkflowConfig; - }; + await import(pathToFileURL(loaded.runtimePath as string).href); expect(globals.__workflowWorldImports).toBeUndefined(); - await runtime.default.world?.(); + await getRuntimeWorkflowConfig()?.world?.(); expect(globals.__workflowWorldImports).toBe(1); }); diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 191cd8b223..e0174a676f 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -19,7 +19,7 @@ import { import type { WorldProvider } from '@workflow/world'; import { findUp } from 'find-up'; import { createJiti } from 'jiti'; -import type { RuntimeWorkflowConfig } from './runtime-binding.js'; +import type { RuntimeWorkflowConfig } from './runtime.js'; import { type WorkflowConfig, WorkflowConfigSchema, @@ -166,7 +166,7 @@ export async function loadWorkflowConfig( mkdirSync(runtimeDir, { recursive: true }); writeFileSync( runtimePath, - `const world = ${worldFactory};\nconst config = { world, queue: ${JSON.stringify(config.queue)} };\nglobalThis[Symbol.for('@workflow/config/runtime')] = config;\nexport default config;\n` + `const world = ${worldFactory};\nglobalThis[Symbol.for('@workflow/config/runtime')] = { world, queue: ${JSON.stringify(config.queue)} };\n` ); return { path, runtimePath, config }; diff --git a/packages/config/src/runtime-binding.ts b/packages/config/src/runtime-binding.ts index 43c40449f3..cb0ff5c3b5 100644 --- a/packages/config/src/runtime-binding.ts +++ b/packages/config/src/runtime-binding.ts @@ -1,10 +1 @@ -import type { WorldProvider } from '@workflow/world'; -import type { WorkflowConfig } from './schema.js'; - -export type RuntimeWorkflowConfig = Pick & { - world?: WorldProvider; -}; - -const config: RuntimeWorkflowConfig | undefined = undefined; - -export default config; +export {}; diff --git a/packages/config/src/runtime.ts b/packages/config/src/runtime.ts index fee86d30e7..77aebbe67c 100644 --- a/packages/config/src/runtime.ts +++ b/packages/config/src/runtime.ts @@ -1,5 +1,10 @@ +import type { WorldProvider } from '@workflow/world'; import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; -import type { RuntimeWorkflowConfig } from './runtime-binding.js'; +import type { WorkflowConfig } from './schema.js'; + +export type RuntimeWorkflowConfig = Pick & { + world?: WorldProvider; +}; const RuntimeWorkflowConfigSymbol = Symbol.for('@workflow/config/runtime'); diff --git a/packages/core/src/runtime.test.ts b/packages/core/src/runtime.test.ts index 1166111ea8..4d9f40c9c2 100644 --- a/packages/core/src/runtime.test.ts +++ b/packages/core/src/runtime.test.ts @@ -8,6 +8,7 @@ import { SPEC_VERSION_CURRENT, type WorkflowRun, } from '@workflow/world'; +import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { registerStepFunction } from './private.js'; import { REPLAY_DIVERGENCE_MAX_RETRIES } from './runtime/constants.js'; @@ -128,6 +129,24 @@ async function runWorkflowHandlerWithEvents( describe('workflowEntrypoint World replacement', () => { afterEach(() => { setWorld(undefined); + setWorkflowQueueNamespace(undefined); + }); + + it('resolves the namespace when the handler binds to its World', async () => { + const createQueueHandler = vi.fn(() => async () => new Response()); + setWorld({ + specVersion: SPEC_VERSION_CURRENT, + createQueueHandler, + } as any); + const entrypoint = workflowEntrypoint(''); + + setWorkflowQueueNamespace('app'); + await entrypoint(new Request('https://example.test')); + + expect(createQueueHandler).toHaveBeenCalledWith( + '__app_wkf_workflow_', + expect.any(Function) + ); }); it('rebuilds its queue handler after the World changes', async () => { diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 04a70cd01f..4d4aa59d3b 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -290,11 +290,10 @@ export function workflowEntrypoint( const NO_INLINE_REPLAY_AFTER_MS = Number(process.env.WORKFLOW_V2_TIMEOUT_MS) || 120_000; - const namespace = resolveQueueNamespace(options?.namespace); - const workflowPrefix = getQueueTopicPrefix('workflow', namespace); - - const handler = (worldHandlers: WorldHandlers) => - worldHandlers.createQueueHandler( + const handler = (worldHandlers: WorldHandlers) => { + const namespace = resolveQueueNamespace(options?.namespace); + const workflowPrefix = getQueueTopicPrefix('workflow', namespace); + return worldHandlers.createQueueHandler( workflowPrefix, async (message_, metadata) => { // Check if this is a health check message @@ -2019,6 +2018,7 @@ export function workflowEntrypoint( }); // End withTraceContext } ); + }; let cachedHandler: ((req: Request) => Promise) | undefined; let cachedWorld: World | undefined; @@ -2055,9 +2055,13 @@ export function workflowEntrypoint( }, async (span) => { if (!cachedHandler || cachedWorld !== world) { - cachedHandler = await trace('workflow.route.init', async () => - handler(world) - ); + cachedHandler = await trace('workflow.route.init', async () => { + const worldHandlers = await trace( + 'workflow.route.get_world_handlers', + async () => world + ); + return handler(worldHandlers); + }); cachedWorld = world; } diff --git a/packages/core/src/runtime/step-handler.test.ts b/packages/core/src/runtime/step-handler.test.ts index fa02252e45..47bf568a53 100644 --- a/packages/core/src/runtime/step-handler.test.ts +++ b/packages/core/src/runtime/step-handler.test.ts @@ -5,6 +5,7 @@ import { ThrottleError, WorkflowWorldError, } from '@workflow/errors'; +import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; import { afterEach, beforeAll, @@ -18,6 +19,7 @@ import { // Use vi.hoisted so these are available in mock factories const { capturedHandlerRef, + capturedPrefixRef, mockEventsCreate, mockQueue, mockRuntimeLogger, @@ -32,6 +34,7 @@ const { capturedHandlerRef: { current: null as null | ((...args: unknown[]) => Promise), }, + capturedPrefixRef: { current: '' }, mockEventsCreate: vi.fn(), mockQueue: vi.fn().mockResolvedValue({ messageId: 'msg_test' }), mockRuntimeLogger: (() => { @@ -71,9 +74,10 @@ vi.mock('./world.js', () => ({ getWorld: vi.fn(async () => ({ createQueueHandler: vi.fn( ( - _prefix: string, + prefix: string, handler: (...args: unknown[]) => Promise ): ((req: Request) => Promise) => { + capturedPrefixRef.current = prefix; capturedHandlerRef.current = handler; return vi.fn() as unknown as (req: Request) => Promise; } @@ -230,6 +234,17 @@ function createMessage(overrides: Record = {}) { }; } +describe('step-handler namespace', () => { + afterEach(() => setWorkflowQueueNamespace(undefined)); + + it('resolves the namespace when the handler binds to its World', async () => { + setWorkflowQueueNamespace('app'); + await stepEntrypoint(new Request('http://localhost')); + + expect(capturedPrefixRef.current).toBe('__app_wkf_step_'); + }); +}); + describe('step-handler 409 handling', () => { // Trigger the lazy handler initialization by calling stepEntrypoint once. // This gets the World and captures the queue handler. diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index 7369761aa3..fcab674d87 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -1154,8 +1154,6 @@ function createStepHandler(namespace?: string) { }); } -const stepHandler = createStepHandler(); - /** * A single route that handles any step execution request and routes to the * appropriate step function. We may eventually want to create different bundles @@ -1167,7 +1165,7 @@ export const stepEntrypoint: (req: Request) => Promise = /* @__PURE__ */ withHealthCheck(async (req) => { const world = await getWorld(); if (!cachedStepHandler || cachedWorld !== world) { - cachedStepHandler = stepHandler(world); + cachedStepHandler = createStepHandler()(world); cachedWorld = world; } return cachedStepHandler(req); diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index 1ecbe17255..655b08110d 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import { getRuntimeWorkflowConfig } from '@workflow/config/runtime'; -import boundWorkflowConfig from '@workflow/config/runtime-binding'; +import '@workflow/config/runtime-binding'; import { isVercelWorldTarget, resolveWorkflowTargetWorld, @@ -40,7 +40,7 @@ const globalSymbols: typeof globalThis & { } = globalThis; function getWorkflowConfig() { - return boundWorkflowConfig ?? getRuntimeWorkflowConfig() ?? {}; + return getRuntimeWorkflowConfig() ?? {}; } export function usesConfiguredWorld(): boolean { diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts index b247eb2f5f..901e57e6c4 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -164,6 +164,21 @@ describe('@workflow/nitro virtual handlers', () => { 'import "@workflow/config/runtime-binding";' ); }); + + it('keeps configured World providers in lazy chunks', async () => { + const project = createProject( + `export default { world: './workflow.world.ts' };` + ); + writeFileSync( + join(project, 'workflow.world.ts'), + `throw new Error('must stay lazy');` + ); + const nitro = createNitroStub({ routing: true, rootDir: project }); + + await nitroModule.setup(nitro); + + expect(nitro.options.inlineDynamicImports).toBe(false); + }); }); describe('@workflow/nitro workflow.config.ts', () => { diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index ea89b91c3f..5c6e5d14e0 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -4,7 +4,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; 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 type { Nitro, RollupConfig } from 'nitro/types'; import { join } from 'pathe'; import { LocalBuilder, VercelBuilder } from './builders.js'; import type { ModuleOptions } from './types'; @@ -38,6 +38,9 @@ export const nitroModule = { }); const workflowConfig = loadedWorkflowConfig.config; const runtimeConfigPath = loadedWorkflowConfig.runtimePath; + if (workflowConfig.world) { + nitro.options.inlineDynamicImports = false; + } const nitroIntegration = workflowConfig.integration?.type === 'nitro' ? workflowConfig.integration @@ -342,12 +345,7 @@ export const nitroModule = { }, }; -export default { - name: nitroModule.name, - async setup(nitro: Nitro) { - await nitroModule.setup(nitro); - }, -} satisfies NitroModule; +export default nitroModule; const DASHBOARD_VIRTUAL_ID = '#workflow/dashboard-handler'; diff --git a/packages/world-postgres/src/config.ts b/packages/world-postgres/src/config.ts index 2c74ff05c9..943e022311 100644 --- a/packages/world-postgres/src/config.ts +++ b/packages/world-postgres/src/config.ts @@ -8,7 +8,7 @@ export type PostgresWorldConfig = PgConnectionConfig & { jobPrefix?: string; /** * namespace for queue topic prefixes (e.g. 'custom' → '__custom_wkf_workflow_'). - * defaults to WORKFLOW_QUEUE_NAMESPACE env var if not provided. + * Used only when no runtime queue namespace is configured. */ namespace?: string; queueConcurrency?: number; diff --git a/packages/world-postgres/src/index.ts b/packages/world-postgres/src/index.ts index 2e980a7731..6ade926518 100644 --- a/packages/world-postgres/src/index.ts +++ b/packages/world-postgres/src/index.ts @@ -5,6 +5,7 @@ import { resolveQueueNamespace, SPEC_VERSION_CURRENT, } from '@workflow/world'; +import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; import { Pool } from 'pg'; import type { PostgresWorldConfig } from './config.js'; import { createClient, type Drizzle } from './drizzle/index.js'; @@ -46,6 +47,7 @@ export function createWorld( 50, } ): World & { start(): Promise } { + let usesProviderNamespace = false; const maxPoolSize = config.maxPoolSize ?? getDefaultMaxPoolSize(); const pool = config.pool || @@ -70,11 +72,15 @@ export function createWorld( streamFlushIntervalMs: config.streamFlushIntervalMs, }), async start() { + if (resolveQueueNamespace() === undefined && config.namespace) { + setWorkflowQueueNamespace(config.namespace); + usesProviderNamespace = true; + } await queue.start(); await reenqueueActiveRuns( storage.runs, queue.queue, - getQueueTopicPrefix('workflow', resolveQueueNamespace(config.namespace)) + getQueueTopicPrefix('workflow', resolveQueueNamespace()) ); }, async close() { @@ -83,6 +89,9 @@ export function createWorld( if (pool !== config.pool) { await pool.end(); } + if (usesProviderNamespace) { + setWorkflowQueueNamespace(undefined); + } }, }; } diff --git a/packages/world-postgres/src/queue.test.ts b/packages/world-postgres/src/queue.test.ts index 83cf885cff..a49916f3b9 100644 --- a/packages/world-postgres/src/queue.test.ts +++ b/packages/world-postgres/src/queue.test.ts @@ -2,6 +2,7 @@ import { createServer, type Server } from 'node:http'; import { JsonTransport } from '@vercel/queue'; import { getWorkflowPort } from '@workflow/utils/get-port'; import { MessageId, parseQueueName, type QueuePayload } from '@workflow/world'; +import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; import { createLocalWorld } from '@workflow/world-local'; import { makeWorkerUtils, run, type WorkerUtils } from 'graphile-worker'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -14,9 +15,7 @@ const createdQueues: Array> = []; const createdServers: Server[] = []; vi.mock('graphile-worker', () => ({ - Logger: class Logger { - constructor(_: unknown) {} - }, + Logger: class Logger {}, makeWorkerUtils: vi.fn(), run: vi.fn(), })); @@ -52,6 +51,8 @@ describe('postgres queue http execution', () => { beforeEach(() => { vi.clearAllMocks(); + setWorkflowQueueNamespace(undefined); + delete process.env.WORKFLOW_QUEUE_NAMESPACE; vi.mocked(makeWorkerUtils).mockResolvedValue(workerUtilsMock); vi.mocked(getWorkflowPort).mockResolvedValue(undefined); @@ -74,6 +75,7 @@ describe('postgres queue http execution', () => { ); vi.useRealTimers(); delete process.env.WORKFLOW_LOCAL_BASE_URL; + delete process.env.WORKFLOW_QUEUE_NAMESPACE; delete process.env.PORT; }); @@ -256,7 +258,7 @@ describe('postgres queue http execution', () => { } }); - it('serializes namespaced workflow queue execution for the same runId', async () => { + it('uses the runtime namespace before the provider fallback', async () => { let resolveFirstRequestStarted!: () => void; const firstRequestStarted = new Promise((resolve) => { resolveFirstRequestStarted = resolve; @@ -283,6 +285,7 @@ describe('postgres queue http execution', () => { }); vi.stubGlobal('fetch', fetchMock); process.env.WORKFLOW_LOCAL_BASE_URL = 'http://localhost:3000'; + process.env.WORKFLOW_QUEUE_NAMESPACE = 'preview'; const queue = buildQueue( { connectionString: 'postgres://test', namespace: 'custom' }, @@ -296,13 +299,13 @@ describe('postgres queue http execution', () => { runId: 'wrun_01ABC', }; const firstExecution = task( - buildMessageData('__custom_wkf_workflow_test-workflow', payload, { + buildMessageData('__preview_wkf_workflow_test-workflow', payload, { messageId: MessageId.parse('msg_01ABC'), }), {} as any ); const secondExecution = task( - buildMessageData('__custom_wkf_workflow_test-workflow', payload, { + buildMessageData('__preview_wkf_workflow_test-workflow', payload, { messageId: MessageId.parse('msg_01ABD'), }), {} as any diff --git a/packages/world-postgres/src/queue.ts b/packages/world-postgres/src/queue.ts index 00db4be87d..25e0be8d90 100644 --- a/packages/world-postgres/src/queue.ts +++ b/packages/world-postgres/src/queue.ts @@ -488,7 +488,7 @@ export function createQueue( string, (payload: unknown, helpers: unknown) => Promise > = {}; - const namespace = resolveQueueNamespace(config.namespace); + const namespace = resolveQueueNamespace() ?? config.namespace; const workflowPrefix = getQueueTopicPrefix('workflow', namespace); const stepPrefix = getQueueTopicPrefix('step', namespace); taskList[getJobQueueName(workflowPrefix)] = diff --git a/packages/world-postgres/src/reenqueue.test.ts b/packages/world-postgres/src/reenqueue.test.ts index 5cd6b49b42..1fd01062bc 100644 --- a/packages/world-postgres/src/reenqueue.test.ts +++ b/packages/world-postgres/src/reenqueue.test.ts @@ -1,11 +1,7 @@ import { getWorkflowPort } from '@workflow/utils/get-port'; +import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; import { createLocalWorld } from '@workflow/world-local'; -import { - Logger, - makeWorkerUtils, - run, - type WorkerUtils, -} from 'graphile-worker'; +import { makeWorkerUtils, run, type WorkerUtils } from 'graphile-worker'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createWorld } from './index.js'; import { @@ -14,12 +10,9 @@ import { createRunsStorage, createStepsStorage, } from './storage.js'; -import { createStreamer } from './streamer.js'; vi.mock('graphile-worker', () => ({ - Logger: class Logger { - constructor(_: unknown) {} - }, + Logger: class Logger {}, makeWorkerUtils: vi.fn(), run: vi.fn(), })); @@ -95,6 +88,8 @@ describe('re-enqueue active runs on start', () => { beforeEach(() => { vi.clearAllMocks(); + setWorkflowQueueNamespace(undefined); + delete process.env.WORKFLOW_QUEUE_NAMESPACE; vi.mocked(makeWorkerUtils).mockResolvedValue(workerUtilsMock); vi.mocked(getWorkflowPort).mockResolvedValue(undefined); vi.mocked(run).mockResolvedValue(runnerMock as any); @@ -112,6 +107,7 @@ describe('re-enqueue active runs on start', () => { afterEach(async () => { delete process.env.WORKFLOW_LOCAL_BASE_URL; + delete process.env.WORKFLOW_QUEUE_NAMESPACE; delete process.env.PORT; }); From e393dbfa48add29177aac6264db81a7cfeb5861f Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:01:17 -0700 Subject: [PATCH 28/28] Simplify unified workflow configuration Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .changeset/framework-shared-config.md | 8 +- .changeset/lazy-world-factories.md | 8 - .changeset/shared-config-runtime.md | 9 - .../workflow-next/with-workflow.mdx | 4 +- .../v5/api-reference/workflow-nitro/index.mdx | 6 +- .../workflow-runtime/close-world.mdx | 34 --- .../workflow-runtime/create-world.mdx | 9 +- .../workflow-runtime/get-world-handlers.mdx | 13 +- .../workflow-runtime/get-world.mdx | 3 + .../api-reference/workflow-runtime/index.mdx | 7 +- .../api-reference/workflow-runtime/meta.json | 1 - .../workflow-runtime/set-world.mdx | 22 +- .../workflow-runtime/workflow-entrypoint.mdx | 2 +- .../docs/v5/foundations/configuration.mdx | 100 +------ packages/astro/package.json | 1 - packages/astro/src/builder.ts | 10 +- packages/astro/src/plugin.ts | 52 ++-- packages/builders/package.json | 18 +- packages/builders/src/base-builder.ts | 97 ++----- packages/builders/src/config-helpers.ts | 4 +- packages/builders/src/constants.test.ts | 6 +- packages/builders/src/constants.ts | 7 +- packages/builders/src/index.ts | 4 +- .../builders/src/resolve-sourcemap.test.ts | 36 +-- packages/builders/src/types.ts | 15 +- .../builders/src/vercel-build-output-api.ts | 14 +- packages/builders/src/workflow-config.cts | 9 + packages/builders/src/workflow-config.ts | 91 ++++++ packages/cli/package.json | 1 - packages/cli/src/base.ts | 5 +- packages/cli/src/commands/health.ts | 2 +- .../src/lib/config/workflow-config.test.ts | 34 --- .../cli/src/lib/config/workflow-config.ts | 12 +- packages/cli/src/lib/inspect/flags.test.ts | 12 - packages/cli/src/lib/inspect/setup.test.ts | 39 +-- packages/cli/src/lib/inspect/setup.ts | 22 +- packages/config/README.md | 8 - packages/config/package.json | 62 ---- packages/config/src/index.ts | 4 - packages/config/src/load.cts | 11 - packages/config/src/load.test.ts | 273 ------------------ packages/config/src/load.ts | 191 ------------ packages/config/src/runtime-binding.ts | 1 - packages/config/src/runtime.ts | 24 -- packages/config/src/schema.ts | 49 ---- packages/config/tsconfig.json | 8 - packages/core/package.json | 1 - packages/core/src/runtime.test.ts | 52 ---- packages/core/src/runtime.ts | 28 +- packages/core/src/runtime/get-world-lazy.ts | 26 +- .../core/src/runtime/step-handler.test.ts | 50 ++-- packages/core/src/runtime/step-handler.ts | 12 +- .../core/src/runtime/world-config.test.ts | 229 --------------- packages/core/src/runtime/world.ts | 261 ++++------------- packages/next/package.json | 1 - packages/next/src/builder-eager.ts | 19 +- packages/next/src/index.test.ts | 165 ++--------- packages/next/src/index.ts | 101 +++---- packages/nitro/package.json | 1 - packages/nitro/src/builders.ts | 53 ++-- packages/nitro/src/index.test.ts | 175 +---------- packages/nitro/src/index.ts | 73 ++--- packages/nitro/src/types.ts | 2 +- packages/nitro/src/vite.ts | 2 +- packages/sveltekit/package.json | 1 - packages/sveltekit/src/builder.ts | 20 +- packages/sveltekit/src/index.ts | 10 +- packages/sveltekit/src/plugin.ts | 32 +- packages/sveltekit/src/vc-config.test.ts | 10 - packages/sveltekit/src/vc-config.ts | 8 +- .../workflow-server-actions.server.test.ts | 29 -- .../server/workflow-server-actions.server.ts | 35 ++- packages/workflow/package.json | 3 +- packages/workflow/src/config.ts | 6 +- packages/workflow/src/runtime.ts | 1 - packages/world-local/src/index.ts | 13 +- packages/world-local/src/reenqueue.test.ts | 28 +- packages/world-postgres/HOW_IT_WORKS.md | 14 +- packages/world-postgres/src/config.ts | 2 +- packages/world-postgres/src/index.ts | 22 +- packages/world-postgres/src/queue.test.ts | 15 +- packages/world-postgres/src/queue.ts | 2 +- packages/world-postgres/src/reenqueue.test.ts | 16 +- packages/world/package.json | 4 + packages/world/src/provider.ts | 3 + packages/world/src/queue.test.ts | 31 +- packages/world/src/queue.ts | 28 +- packages/world/src/recovery.ts | 14 +- pnpm-lock.yaml | 64 +--- scripts/stage-workbench-with-tarballs.mjs | 50 ++-- 90 files changed, 703 insertions(+), 2357 deletions(-) delete mode 100644 .changeset/lazy-world-factories.md delete mode 100644 .changeset/shared-config-runtime.md delete mode 100644 docs/content/docs/v5/api-reference/workflow-runtime/close-world.mdx create mode 100644 packages/builders/src/workflow-config.cts create mode 100644 packages/builders/src/workflow-config.ts delete mode 100644 packages/cli/src/lib/config/workflow-config.test.ts delete mode 100644 packages/cli/src/lib/inspect/flags.test.ts delete mode 100644 packages/config/README.md delete mode 100644 packages/config/package.json delete mode 100644 packages/config/src/index.ts delete mode 100644 packages/config/src/load.cts delete mode 100644 packages/config/src/load.test.ts delete mode 100644 packages/config/src/load.ts delete mode 100644 packages/config/src/runtime-binding.ts delete mode 100644 packages/config/src/runtime.ts delete mode 100644 packages/config/src/schema.ts delete mode 100644 packages/config/tsconfig.json delete mode 100644 packages/core/src/runtime/world-config.test.ts delete mode 100644 packages/web/app/server/workflow-server-actions.server.test.ts create mode 100644 packages/world/src/provider.ts diff --git a/.changeset/framework-shared-config.md b/.changeset/framework-shared-config.md index 2cfc706ea3..f73989232b 100644 --- a/.changeset/framework-shared-config.md +++ b/.changeset/framework-shared-config.md @@ -3,6 +3,12 @@ "@workflow/nitro": minor "@workflow/sveltekit": minor "@workflow/astro": minor +"@workflow/builders": minor +"@workflow/cli": minor +"@workflow/core": minor +"@workflow/web": patch +"@workflow/world": minor +"workflow": minor --- -Add typed shared configuration support to framework integrations. +Add typed configuration for shared build settings and lazy World providers. diff --git a/.changeset/lazy-world-factories.md b/.changeset/lazy-world-factories.md deleted file mode 100644 index 37ad40477a..0000000000 --- a/.changeset/lazy-world-factories.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@workflow/config": minor -"@workflow/world": minor -"@workflow/world-local": patch -"@workflow/world-postgres": patch ---- - -Add typed Workflow configuration with module-based lazy World providers. diff --git a/.changeset/shared-config-runtime.md b/.changeset/shared-config-runtime.md deleted file mode 100644 index d763e8fdb4..0000000000 --- a/.changeset/shared-config-runtime.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@workflow/builders": minor -"@workflow/cli": minor -"@workflow/core": minor -"@workflow/web": patch -"workflow": minor ---- - -Bundle configured World modules and queue settings across runtime, build, and CLI entry points. diff --git a/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx b/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx index 98ef46651d..30145b7a28 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 @@ -77,8 +77,8 @@ Use the smallest directory that contains every workspace package imported by you ## Options -Use [`workflow.config.ts`](/docs/foundations/configuration) for World, queue, -and shared build settings. The optional second argument to `withWorkflow` +Use [`workflow.config.ts`](/docs/foundations/configuration) for the World and +shared build settings. The optional second argument to `withWorkflow` overrides environment variables and `workflow.config.ts`. ```typescript title="next.config.ts" lineNumbers 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 4d5f250d4f..f19d8e8514 100644 --- a/docs/content/docs/v5/api-reference/workflow-nitro/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-nitro/index.mdx @@ -21,9 +21,9 @@ 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. +Shared build settings can also be placed in +[`workflow.config.ts`](/docs/foundations/configuration). Nitro-specific settings +remain under `workflow` in `nitro.config.ts`. When enabled, the module: diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/close-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/close-world.mdx deleted file mode 100644 index df34364d8e..0000000000 --- a/docs/content/docs/v5/api-reference/workflow-runtime/close-world.mdx +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: closeWorld -description: Close and clear the World managed by the workflow runtime. -type: reference -summary: Release the active World's resources and reset the runtime cache. -prerequisites: - - /docs/api-reference/workflow-runtime/get-world -related: - - /docs/api-reference/workflow-runtime/set-world ---- - -Closes the cached [World](/docs/api-reference/workflow-runtime/world) and clears -it from the runtime. If a World is still starting, `closeWorld()` waits for -startup before closing it. It does not create a World when the cache is empty. - -```typescript lineNumbers -import { closeWorld } from "workflow/runtime"; - -await closeWorld(); // [!code highlight] -``` - -The next [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) call -resolves the active target override, configured provider, or automatic World -again. - -## API Signature - -### Parameters - -This function does not accept any parameters. - -### Returns - -Returns a `Promise`. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx index a67cbf33bd..99af06969d 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx @@ -1,8 +1,8 @@ --- title: createWorld -description: Resolve a new World instance from the active Workflow configuration. +description: Create a new World instance from the active Workflow configuration. type: reference -summary: Resolve a fresh World without using the managed runtime cache. +summary: Resolve a fresh World without using the runtime cache. prerequisites: - /docs/api-reference/workflow-runtime/get-world related: @@ -10,9 +10,8 @@ related: --- Creates a new [World](/docs/api-reference/workflow-runtime/world) using the -same selection order as [`getWorld()`](/docs/api-reference/workflow-runtime/get-world): -`WORKFLOW_TARGET_WORLD`, the provider in `workflow.config.ts`, then the -automatic Vercel or Local World. +[configured World selection order](/docs/foundations/configuration#world-selection). +`WORKFLOW_TARGET_WORLD` takes precedence over `workflow.config.ts`. Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), which caches a singleton instance, `createWorld()` constructs a fresh instance on every call. Application code should almost always use `getWorld()` — `createWorld()` is for infrastructure code that manages World lifecycles itself. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx index be2b8943cb..453168cf46 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx @@ -1,16 +1,15 @@ --- title: getWorldHandlers -description: Resolve the active World's queue handlers. +description: Access the World's queue handler factory and spec version. type: reference -summary: Access queue handler creation through the managed runtime World. +summary: Resolve the World members used by Workflow runtime handlers. prerequisites: - /docs/api-reference/workflow-runtime/get-world --- -Returns `createQueueHandler` and `specVersion` from the same managed -[World](/docs/api-reference/workflow-runtime/world) as -[`getWorld()`](/docs/api-reference/workflow-runtime/get-world). The first call -resolves and caches the World, starting configured providers before returning. +Returns a restricted view of the [World](/docs/api-reference/workflow-runtime/world) exposing `createQueueHandler` and `specVersion`. + +Configured providers share the instance cached by [`getWorld()`](/docs/api-reference/workflow-runtime/get-world). Legacy environment-based Worlds keep a separate handler cache. ```typescript lineNumbers import { getWorldHandlers } from "workflow/runtime"; @@ -36,7 +35,7 @@ type WorldHandlers = Pick; ``` - This is a low-level infrastructure API. Application code should use [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) instead. + This is SDK infrastructure used by framework adapters and the workflow entrypoint. Application code should use [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) instead. ## Related Functions diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/get-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/get-world.mdx index f094291e18..e002e92fc5 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/get-world.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/get-world.mdx @@ -9,6 +9,9 @@ prerequisites: Retrieves the World instance for direct access to workflow storage, queuing, and streaming backends. This async function returns a `Promise` which provides low-level access to manage workflow runs, steps, events, and hooks. +The first call resolves and caches the selected World. Worlds loaded through a +provider in `workflow.config.ts` are also started before they are returned. + Use this function when you need direct access to the underlying workflow infrastructure, such as listing all runs, querying events, or implementing custom workflow management logic. ```typescript lineNumbers diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx index d6ece115fc..042e181c11 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx @@ -26,16 +26,13 @@ These functions are primarily used by framework adapters and custom world setups - Resolve a fresh World without using the managed cache. + Create a World instance from environment configuration. Override the cached World instance with a custom World. - - Close and clear the managed World. - - Resolve queue handlers from the managed World. + Build-time-safe access to the World's queue handlers. Create the HTTP route handler that executes workflow runs. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/meta.json b/docs/content/docs/v5/api-reference/workflow-runtime/meta.json index 97b9205b96..c8081938a0 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/meta.json +++ b/docs/content/docs/v5/api-reference/workflow-runtime/meta.json @@ -5,7 +5,6 @@ "world", "create-world", "set-world", - "close-world", "get-world-handlers", "workflow-entrypoint", "health-check" diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx index a3e9420203..6dcc03d8bd 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx @@ -1,20 +1,15 @@ --- title: setWorld -description: Override the cached World instance used by the workflow runtime. +description: Override or reset the cached World instance used by the workflow runtime. type: reference -summary: Inject a custom World instance into the runtime cache. +summary: Use setWorld to inject a custom World instance or reset the cache after environment configuration changes. prerequisites: - /docs/api-reference/workflow-runtime/get-world related: - /docs/api-reference/workflow-runtime/create-world - - /docs/api-reference/workflow-runtime/close-world --- -Overrides the cached [World](/docs/api-reference/workflow-runtime/world) -instance that [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) -returns. Passing `undefined` can clear a manually injected World. Use -[`closeWorld()`](/docs/api-reference/workflow-runtime/close-world) to close and -reset a World started from `workflow.config.ts`. +Overrides the cached [World](/docs/api-reference/workflow-runtime/world) instance that [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) returns. Use it to inject a World constructed with explicit configuration (rather than environment variables), or pass `undefined` to clear the cache so the next `getWorld()` call reinitializes from the current environment. ```typescript lineNumbers import { setWorld, getWorld } from "workflow/runtime"; @@ -31,19 +26,19 @@ const world = await getWorld(); // resolves customWorld | Parameter | Type | Description | |-----------|------|-------------| -| `world` | `World \| undefined` | The World instance to use, or `undefined` to clear a manually injected instance | +| `world` | `World \| undefined` | The World instance to use, or `undefined` to reset the cache and reinitialize from environment variables on next access | ### Returns This function does not return a value. -## Resetting the Managed World +## Example: Reset After Environment Changes ```typescript lineNumbers -import { closeWorld, getWorld } from "workflow/runtime"; +import { setWorld, getWorld } from "workflow/runtime"; process.env.WORKFLOW_TARGET_WORLD = "@workflow/world-local"; -await closeWorld(); // close and clear the cached instance // [!code highlight] +setWorld(undefined); // clear the cached instance // [!code highlight] const world = await getWorld(); // reinitialized with new configuration ``` @@ -51,5 +46,4 @@ const world = await getWorld(); // reinitialized with new configuration ## Related Functions - [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the cached World instance. -- [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) - Resolve a fresh World without using the cache. -- [`closeWorld()`](/docs/api-reference/workflow-runtime/close-world) - Close and clear the managed World. +- [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) - Construct a fresh World from environment configuration. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx index f23acfea8c..efce612990 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx @@ -38,5 +38,5 @@ Returns a fetch-style request handler: `(req: Request) => Promise`. ## Related Functions -- [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the World used by the handler. +- [`getWorldHandlers()`](/docs/api-reference/workflow-runtime/get-world-handlers) - The build-time World access this handler is built on. - [`healthCheck()`](/docs/api-reference/workflow-runtime/health-check) - Verify the entrypoint processes queue messages end-to-end. diff --git a/docs/content/docs/v5/foundations/configuration.mdx b/docs/content/docs/v5/foundations/configuration.mdx index 01bf37beb7..410860d448 100644 --- a/docs/content/docs/v5/foundations/configuration.mdx +++ b/docs/content/docs/v5/foundations/configuration.mdx @@ -2,7 +2,7 @@ title: Configuration description: Configure Workflow SDK with a typed workflow.config.ts file. type: conceptual -summary: Configure the World, builds, queue namespace, and framework integration for your app. +summary: Configure the World and shared build settings for your app. prerequisites: - /docs/foundations/workflows-and-steps related: @@ -22,16 +22,12 @@ const config = { dirs: ["workflows"], sourcemap: false, }, - queue: { - namespace: "myapp", - }, } satisfies WorkflowConfig; export default config; ``` -The World module default-exports a lazy provider. Read runtime credentials from -environment variables here rather than placing them in the build configuration: +`world` points to a module that default-exports a lazy provider: ```typescript title="workflow.world.ts" lineNumbers import type { WorldProvider } from "workflow/config"; @@ -48,55 +44,29 @@ const world: WorldProvider = () => export default world; ``` - - Next.js projects also wrap `next.config.ts` with `withWorkflow()`. - - -In a monorepo, place a config file in each application that needs its own -settings. +The provider is bundled with the application and runs when the runtime first +needs the World. Keep credentials in environment variables rather than +`workflow.config.ts`. ## Precedence -From highest to lowest priority: +Configuration is resolved in this order: -1. Explicit CLI flags or framework options +1. Explicit CLI or framework options 2. Environment variables 3. `workflow.config.ts` 4. Built-in defaults -## World - -`world` is an optional path relative to `workflow.config.ts`, or a package -specifier. Its module must default-export a `WorldProvider`. The module is -included in the runtime output, but the provider runs only when the application -first needs its World. - -World selection follows this order: - -| Condition | Selected World | -| --- | --- | -| `WORKFLOW_TARGET_WORLD` is set | The built-in target or module named by the environment variable | -| `world` is configured | The configured provider | -| `VERCEL_DEPLOYMENT_ID` is set | Vercel World | -| Otherwise | Local World | - -You do not need a `workflow.world.ts` file when the automatic Local and Vercel -Worlds are sufficient. A config file may also omit `world` while configuring -build or queue settings. +## World Selection -A missing configured module fails during build or development setup. If a -provider throws or returns no World, runtime startup fails; Workflow does not -silently switch to another backend. +`world` accepts a path relative to `workflow.config.ts` or a package specifier. +`WORKFLOW_TARGET_WORLD` overrides it. Without either setting, Workflow uses the +Vercel World on Vercel and the Local World elsewhere. -## Deployment Environments +### Different Worlds Per Environment -For local development and Vercel deployments, omit `world` to use the automatic -Local and Vercel Worlds. - -When environments need different custom Worlds or options, select them inside -the provider using an application environment variable set by each deployment. -Use a dedicated variable when you need to distinguish preview from production, -because both commonly use `NODE_ENV=production`. +Select between Worlds inside the provider. Use an application-specific variable +when preview and production both run with `NODE_ENV=production`. ```typescript title="workflow.world.ts" lineNumbers import type { WorldProvider } from "workflow/config"; @@ -114,55 +84,17 @@ const world: WorldProvider = () => { case "test": return createLocalWorld(); default: - throw new Error( - `Unexpected APP_ENV: ${process.env.APP_ENV}`, - ); + throw new Error(`Unexpected APP_ENV: ${process.env.APP_ENV}`); } }; export default world; ``` -Set `WORKFLOW_POSTGRES_URL` independently in preview and production to keep -credentials out of source control and point each deployment at the correct -database. - -## Build +## Build Options | Option | Type | Description | | --- | --- | --- | | `build.dirs` | `string[]` | Directories to scan for workflow code | | `build.projectRoot` | `string` | Root for tracing, module resolution, and TypeScript configuration | -| `build.externalPackages` | `string[]` | Packages excluded from generated bundles. Direct Vercel Build Output API builds bundle all dependencies. | | `build.sourcemap` | `boolean \| "inline" \| "linked" \| "external" \| "both"` | Source map mode for generated bundles | -| `build.manifest.public` | `boolean` | Expose the workflow manifest over HTTP | -| `build.manifest.output` | `string` | Write the workflow manifest to this path | - -## Queue Namespace - -`queue.namespace` prefixes Workflow queue topics and generated deployment -triggers. It must be lowercase alphanumeric and start with a letter. - -For Postgres Worlds that share a database, also give each application a unique -`jobPrefix`. The namespace distinguishes Workflow topics; `jobPrefix` -distinguishes the underlying Graphile Worker tasks. - -## Integration Settings - -Use `integration` only for framework-specific settings. It is a discriminated -object, so settings for different integrations cannot be mixed. Nitro currently -supports integration settings: - -```typescript title="workflow.config.ts" lineNumbers -import type { WorkflowConfig } from "workflow/config"; - -const config = { - integration: { - type: "nitro", - typescriptPlugin: true, - runtime: "nodejs24.x", - }, -} satisfies WorkflowConfig; - -export default config; -``` diff --git a/packages/astro/package.json b/packages/astro/package.json index b069936ccf..25c1c4be3c 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -27,7 +27,6 @@ "dependencies": { "@swc/core": "1.15.3", "@workflow/builders": "workspace:*", - "@workflow/config": "workspace:*", "@workflow/rollup": "workspace:*", "@workflow/swc-plugin": "workspace:*", "@workflow/vite": "workspace:*", diff --git a/packages/astro/src/builder.ts b/packages/astro/src/builder.ts index cc50ed9fd3..8e7692e21f 100644 --- a/packages/astro/src/builder.ts +++ b/packages/astro/src/builder.ts @@ -21,11 +21,10 @@ const WORKFLOW_ROUTES = [ export class LocalBuilder extends BaseBuilder { constructor(config: Partial = {}) { const workingDir = config.workingDir ?? process.cwd(); - const build = config.workflowConfig?.config.build; super({ ...config, - dirs: config.dirs ?? build?.dirs ?? ['src/pages', 'src/workflows'], + dirs: config.dirs ?? ['src/pages', 'src/workflows'], buildTarget: 'astro' as const, workingDir, debugFilePrefix: '_', // Prefix with underscore so Astro ignores debug files @@ -91,11 +90,9 @@ export const prerender = false;` // Expose manifest as a public HTTP route when WORKFLOW_PUBLIC_MANIFEST=1 // Astro maps `foo.json.js` to the URL `/foo.json` - const publicManifestPath = join(workflowGeneratedDir, 'manifest.json.js'); - await rm(publicManifestPath, { force: true }); if (this.shouldExposePublicManifest && manifestJson) { await writeFile( - publicManifestPath, + join(workflowGeneratedDir, 'manifest.json.js'), `export function GET() { return new Response(${JSON.stringify(manifestJson)}, { headers: { "content-type": "application/json" }, @@ -163,11 +160,10 @@ export const prerender = false;` export class VercelBuilder extends VercelBuildOutputAPIBuilder { constructor(config: Partial = {}) { const workingDir = config.workingDir ?? process.cwd(); - const build = config.workflowConfig?.config.build; super({ ...config, workingDir, - dirs: config.dirs ?? build?.dirs ?? ['src/pages', 'src/workflows'], + dirs: config.dirs ?? ['src/pages', 'src/workflows'], buildTarget: 'vercel-build-output-api', debugFilePrefix: '_', }); diff --git a/packages/astro/src/plugin.ts b/packages/astro/src/plugin.ts index ad8bfcbf8b..351e81500a 100644 --- a/packages/astro/src/plugin.ts +++ b/packages/astro/src/plugin.ts @@ -1,10 +1,9 @@ import { fileURLToPath } from 'node:url'; import { createBuildQueue } from '@workflow/builders'; -import type { SourcemapMode } from '@workflow/config'; import { - type LoadedWorkflowConfig, loadWorkflowConfig, -} from '@workflow/config/load'; + type SourcemapMode, +} from '@workflow/builders/workflow-config'; import { workflowTransformPlugin } from '@workflow/rollup'; import { workflowHotUpdatePlugin } from '@workflow/vite'; import type { AstroIntegration, HookParameters } from 'astro'; @@ -23,10 +22,8 @@ export interface WorkflowPluginOptions { export function workflowPlugin( options: WorkflowPluginOptions = {} ): AstroIntegration { - let builderConfig: { - workingDir: string; - workflowConfig: LoadedWorkflowConfig; - }; + let builderConfig: ConstructorParameters[0]; + let worldModule: string | undefined; let builder: LocalBuilder; const enqueue = createBuildQueue(); @@ -38,36 +35,34 @@ export function workflowPlugin( updateConfig, }: HookParameters<'astro:config:setup'>) => { const workingDir = fileURLToPath(config.root); + const loaded = await loadWorkflowConfig({ cwd: workingDir }); + const build = loaded.config.build; + worldModule = loaded.worldModule; builderConfig = { workingDir, - workflowConfig: await loadWorkflowConfig({ - cwd: workingDir, - integration: 'astro', - }), + dirs: build?.dirs, + projectRoot: build?.projectRoot, + worldModule, + sourcemap: + options.sourcemap ?? + (process.env.WORKFLOW_SOURCEMAP ? undefined : build?.sourcemap), }; - builder = new LocalBuilder({ - ...builderConfig, - sourcemap: options.sourcemap, - }); + builder = new LocalBuilder(builderConfig); if (!process.env.VERCEL_DEPLOYMENT_ID) { await builder.build(); } updateConfig({ vite: { - ...(builderConfig.workflowConfig.runtimePath - ? { ssr: { noExternal: ['workflow', '@workflow/core'] } } + ...(worldModule + ? { + resolve: { + alias: { '@workflow/world/provider': worldModule }, + }, + ssr: { noExternal: ['workflow', '@workflow/core'] }, + } : {}), plugins: [ workflowTransformPlugin(), - { - name: 'workflow:runtime-config', - enforce: 'pre', - resolveId(source) { - if (source === '@workflow/config/runtime-binding') { - return builderConfig.workflowConfig.runtimePath; - } - }, - }, // Cast needed due to Astro using a different internal Vite version workflowHotUpdatePlugin({ builder: () => builder, @@ -79,10 +74,7 @@ export function workflowPlugin( }, 'astro:build:done': async () => { if (process.env.VERCEL_DEPLOYMENT_ID) { - const vercelBuilder = new VercelBuilder({ - ...builderConfig, - sourcemap: options.sourcemap, - }); + const vercelBuilder = new VercelBuilder(builderConfig); await vercelBuilder.build(); } }, diff --git a/packages/builders/package.json b/packages/builders/package.json index e967a9d0e7..3bb9dbe701 100644 --- a/packages/builders/package.json +++ b/packages/builders/package.json @@ -13,6 +13,16 @@ "./base-builder": { "types": "./dist/base-builder.d.ts", "default": "./dist/base-builder.js" + }, + "./workflow-config": { + "import": { + "types": "./dist/workflow-config.d.ts", + "default": "./dist/workflow-config.js" + }, + "require": { + "types": "./dist/workflow-config.d.cts", + "default": "./dist/workflow-config.cjs" + } } }, "files": [ @@ -40,17 +50,19 @@ }, "dependencies": { "@swc/core": "catalog:", - "@workflow/config": "workspace:*", "@workflow/core": "workspace:*", "@workflow/errors": "workspace:*", - "@workflow/utils": "workspace:*", "@workflow/swc-plugin": "workspace:*", + "@workflow/utils": "workspace:*", + "@workflow/world": "workspace:*", "builtin-modules": "5.0.0", "chalk": "5.6.2", "enhanced-resolve": "catalog:", "esbuild": "catalog:", "find-up": "7.0.0", + "jiti": "2.7.0", "json5": "2.2.3", - "tinyglobby": "0.2.17" + "tinyglobby": "0.2.17", + "zod": "catalog:" } } diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index a738f2a9d3..0bdb3147de 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -37,7 +37,7 @@ import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js'; import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js'; import { createSwcPlugin } from './swc-esbuild-plugin.js'; import { detectWorkflowPatterns } from './transform-utils.js'; -import type { BuilderConfig, SourcemapMode } from './types.js'; +import type { SourcemapMode, WorkflowConfig } from './types.js'; import { extractWorkflowGraphs } from './workflows-extractor.js'; const enhancedResolve = promisify(enhancedResolveOriginal); @@ -217,7 +217,7 @@ function mergeWorkflowManifest( * Subclasses must implement the build() method to define builder-specific logic. */ export abstract class BaseBuilder< - TConfig extends BuilderConfig = BuilderConfig, + TConfig extends WorkflowConfig = WorkflowConfig, > { protected config: TConfig; @@ -234,11 +234,8 @@ export abstract class BaseBuilder< } protected get transformProjectRoot(): string { - const projectRoot = - this.config.projectRoot ?? - this.config.workflowConfig?.config.build?.projectRoot; - return projectRoot - ? resolve(this.config.workingDir, projectRoot) + return this.config.projectRoot + ? resolve(this.config.workingDir, this.config.projectRoot) : this.config.workingDir; } @@ -246,36 +243,9 @@ export abstract class BaseBuilder< return this.config.moduleSpecifierRoot || this.transformProjectRoot; } - protected get queueNamespace(): string | undefined { - return ( - process.env.WORKFLOW_QUEUE_NAMESPACE ?? - this.config.workflowConfig?.config.queue?.namespace - ); - } - - protected get externalPackages(): string[] { - if (this.config.buildTarget === 'vercel-build-output-api') return []; - return ( - this.config.externalPackages ?? - this.config.workflowConfig?.config.build?.externalPackages ?? - [] - ); - } - - private get runtimeConfigPlugins(): esbuild.Plugin[] { - const path = this.config.workflowConfig?.runtimePath; - if (!path) return []; - return [ - { - name: 'workflow-runtime-config', - setup(build) { - build.onResolve( - { filter: /^@workflow\/config\/runtime-binding$/ }, - () => ({ path }) - ); - }, - }, - ]; + private get worldAlias(): Record | undefined { + if (!this.config.worldModule) return undefined; + return { '@workflow/world/provider': this.config.worldModule }; } protected logBaseBuilderInfo(...args: unknown[]): void { @@ -440,8 +410,8 @@ export abstract class BaseBuilder< * workflow compiler when the package is externalized. */ private async warnAboutExternalWorkflowPackages(): Promise { - const externalPackages = this.externalPackages; - if (!externalPackages.length) return; + const externalPackages = this.config.externalPackages; + if (!externalPackages?.length) return; for (const pkg of externalPackages) { if (BaseBuilder.PSEUDO_PACKAGES.has(pkg)) continue; @@ -1146,7 +1116,7 @@ export const __steps_registered = true; ], // Plugin should catch most things, but this lets users hard override // if the plugin misses anything that should be externalized - external: ['bun', 'bun:*', ...this.externalPackages], + external: ['bun', 'bun:*', ...(this.config.externalPackages || [])], }); const stepsResult = await esbuildCtx.rebuild(); @@ -1410,11 +1380,11 @@ export const __steps_registered = true; `${Date.now() - bundleStartTime}ms` ); - const workflowManifestPath = - this.config.workflowManifestPath ?? - this.config.workflowConfig?.config.build?.manifest?.output; - if (workflowManifestPath) { - const resolvedPath = this.resolvePath(workflowManifestPath); + if (this.config.workflowManifestPath) { + const resolvedPath = resolve( + process.cwd(), + this.config.workflowManifestPath + ); let prefix = ''; if (resolvedPath.endsWith('.cjs')) { @@ -1485,7 +1455,6 @@ export const __steps_registered = true; const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode( { - namespace: this.queueNamespace, routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt', } ); @@ -1542,11 +1511,8 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo write: true, keepNames: true, minify: false, - external: [ - '@aws-sdk/credential-provider-web-identity', - ...this.externalPackages, - ], - plugins: this.runtimeConfigPlugins, + external: ['@aws-sdk/credential-provider-web-identity'], + alias: this.worldAlias, }); this.logEsbuildMessages( @@ -1685,7 +1651,6 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo const stepsRelativePath = `./${basename(stepsOutfile).replace(/\\/g, '/')}`; const escapedVMCode = workflowVMCode.replace(/[\\`$]/g, '\\$&'); const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode({ - namespace: this.queueNamespace, routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt', }); @@ -1734,11 +1699,8 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo keepNames: true, minify: false, define: importMetaDefine, - external: [ - '@aws-sdk/credential-provider-web-identity', - ...this.externalPackages, - ], - plugins: this.runtimeConfigPlugins, + external: ['@aws-sdk/credential-provider-web-identity'], + alias: this.worldAlias, }); this.logEsbuildMessages(finalResult, 'combined bundle', true); this.logBaseBuilderInfo( @@ -1765,7 +1727,6 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo const escaped = interimBundleText.replace(/[\\`$]/g, '\\$&'); const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode( { - namespace: this.queueNamespace, routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt', } ); @@ -2020,8 +1981,9 @@ export const OPTIONS = handler;`; ], sourcemap: this.resolveSourcemap(EMIT_SOURCEMAPS_FOR_DEBUGGING), mainFields: ['module', 'main'], - external: this.externalPackages, - plugins: this.runtimeConfigPlugins, + // Don't externalize anything - bundle everything including workflow packages + external: [], + alias: this.worldAlias, }); this.logEsbuildMessages(result, 'webhook bundle creation'); @@ -2158,14 +2120,10 @@ export const OPTIONS = handler;`; /** * Whether the manifest should be exposed as a public HTTP route. - * WORKFLOW_PUBLIC_MANIFEST takes precedence over workflow.config.ts. + * Controlled by the `WORKFLOW_PUBLIC_MANIFEST` environment variable. */ protected get shouldExposePublicManifest(): boolean { - if (process.env.WORKFLOW_PUBLIC_MANIFEST !== undefined) { - return process.env.WORKFLOW_PUBLIC_MANIFEST === '1'; - } - - return this.config.workflowConfig?.config.build?.manifest?.public ?? false; + return process.env.WORKFLOW_PUBLIC_MANIFEST === '1'; } /** @@ -2214,16 +2172,13 @@ export const OPTIONS = handler;`; /** * Resolve the effective source map mode for a given call site. Precedence: - * builder option > WORKFLOW_SOURCEMAP > workflow.config.ts > the call site's - * default. Returned value is passed directly to esbuild's `sourcemap` - * option. + * builder option > 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 envMode = parseSourcemapEnv(process.env.WORKFLOW_SOURCEMAP); if (envMode !== undefined) return envMode; - const configMode = this.config.workflowConfig?.config.build?.sourcemap; - if (configMode !== undefined) return configMode; return defaultMode; } diff --git a/packages/builders/src/config-helpers.ts b/packages/builders/src/config-helpers.ts index 40a9473e01..35412c16f0 100644 --- a/packages/builders/src/config-helpers.ts +++ b/packages/builders/src/config-helpers.ts @@ -86,7 +86,7 @@ export async function getDecoratorOptionsForDirectoryWithConfigPath( } export function createBaseBuilderConfig( - config: BaseBuilderConfig + options: Omit & { dirs?: string[] } ): BaseBuilderConfig { - return config; + return { ...options, dirs: options.dirs ?? ['workflows'] }; } diff --git a/packages/builders/src/constants.test.ts b/packages/builders/src/constants.test.ts index c92ee69aab..3e086100f9 100644 --- a/packages/builders/src/constants.test.ts +++ b/packages/builders/src/constants.test.ts @@ -37,7 +37,7 @@ describe('createWorkflowEntrypointOptionsCode', () => { it('inlines an explicit namespace', () => { expect(createWorkflowEntrypointOptionsCode({ namespace: 'custom' })).toBe( - ', { namespace: process.env.WORKFLOW_QUEUE_NAMESPACE ?? "custom" }' + ', { namespace: "custom" }' ); }); @@ -45,7 +45,7 @@ describe('createWorkflowEntrypointOptionsCode', () => { process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom'; expect(createWorkflowEntrypointOptionsCode()).toBe( - ', { namespace: process.env.WORKFLOW_QUEUE_NAMESPACE ?? "custom" }' + ', { namespace: "custom" }' ); }); @@ -56,7 +56,7 @@ describe('createWorkflowEntrypointOptionsCode', () => { routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt', }) ).toBe( - ', { namespace: process.env.WORKFLOW_QUEUE_NAMESPACE ?? "custom", routeModuleBodyStartedAt: workflowRouteModuleBodyStartedAt }' + ', { namespace: "custom", routeModuleBodyStartedAt: workflowRouteModuleBodyStartedAt }' ); }); }); diff --git a/packages/builders/src/constants.ts b/packages/builders/src/constants.ts index 3391b3099a..7d063890e8 100644 --- a/packages/builders/src/constants.ts +++ b/packages/builders/src/constants.ts @@ -49,7 +49,8 @@ export function createWorkflowQueueTrigger(options?: { namespace?: string }) { /** * Creates the optional second argument for generated `workflowEntrypoint()` - * calls. Runtime environment variables override the build-time fallback. + * 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; @@ -62,9 +63,7 @@ export function createWorkflowEntrypointOptionsCode(options?: { if (namespace) { // Reuse prefix construction for namespace validation. getQueueTopicPrefix('workflow', namespace); - fields.push( - `namespace: process.env.WORKFLOW_QUEUE_NAMESPACE ?? ${JSON.stringify(namespace)}` - ); + fields.push(`namespace: ${JSON.stringify(namespace)}`); } if (options?.routeModuleBodyStartedAt) { diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 27299b3ef5..7280975239 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -50,10 +50,9 @@ export { } from './transform-utils.js'; export type { AstroConfig, - BaseBuilderConfig, - BuilderConfig, BuildTarget, NextConfig, + SourcemapMode, StandaloneConfig, SvelteKitConfig, VercelBuildOutputConfig, @@ -62,3 +61,4 @@ export type { export { isValidBuildTarget, validBuildTargets } from './types.js'; export { VercelBuildOutputAPIBuilder } from './vercel-build-output-api.js'; export { resolveWorkflowAliasRelativePath } from './workflow-alias.js'; +export type { WorkflowConfigLoader } from './workflow-config.js'; diff --git a/packages/builders/src/resolve-sourcemap.test.ts b/packages/builders/src/resolve-sourcemap.test.ts index 83df9d60c4..774e8a7a36 100644 --- a/packages/builders/src/resolve-sourcemap.test.ts +++ b/packages/builders/src/resolve-sourcemap.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { BaseBuilder } from './base-builder.js'; -import type { NextConfig, SourcemapMode } from './types.js'; +import type { SourcemapMode, StandaloneConfig } from './types.js'; /** * Minimal subclass that exposes the protected `resolveSourcemap()` and @@ -30,22 +30,17 @@ class TestBuilder extends BaseBuilder { function createBuilder( sourcemap?: SourcemapMode, - options: { watch?: boolean; workflowSourcemap?: SourcemapMode } = {} + watch?: boolean ): TestBuilder { - const config: NextConfig = { - buildTarget: 'next', + const config: StandaloneConfig = { + buildTarget: 'standalone', workingDir: '/tmp/workflow-test', dirs: ['.'], + stepsBundlePath: '', + workflowsBundlePath: '', + webhookBundlePath: '', sourcemap, - watch: options.watch, - workflowConfig: - options.workflowSourcemap === undefined - ? undefined - : { - path: '/tmp/workflow.config.ts', - runtimePath: '/tmp/runtime-config.mjs', - config: { build: { sourcemap: options.workflowSourcemap } }, - }, + watch, }; return new TestBuilder(config); } @@ -89,15 +84,6 @@ describe('resolveSourcemap', () => { ); }); - it('prefers environment variable over workflow.config.ts', () => { - process.env.WORKFLOW_SOURCEMAP = 'inline'; - expect( - createBuilder(undefined, { - workflowSourcemap: false, - }).callResolveSourcemap(true) - ).toBe('inline'); - }); - it('uses environment variable when config is not set', () => { process.env.WORKFLOW_SOURCEMAP = 'false'; expect(createBuilder().callResolveSourcemap('inline')).toBe(false); @@ -166,7 +152,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, { watch: true }); + const builder = createBuilder(undefined, true); expect(builder.publicIsDevelopmentBuild).toBe(true); expect(builder.publicDefaultSourcemapMode).toBe('inline'); }); @@ -208,9 +194,7 @@ describe('sourcemapsEnabled', () => { }); it('is true by default in development (watch)', () => { - expect( - createBuilder(undefined, { watch: true }).publicSourcemapsEnabled - ).toBe(true); + expect(createBuilder(undefined, true).publicSourcemapsEnabled).toBe(true); }); it('is true by default in development (NODE_ENV)', () => { diff --git a/packages/builders/src/types.ts b/packages/builders/src/types.ts index 2fc134fb0d..3679ae1e3e 100644 --- a/packages/builders/src/types.ts +++ b/packages/builders/src/types.ts @@ -1,7 +1,6 @@ -import type { SourcemapMode } from '@workflow/config'; -import type { LoadedWorkflowConfig } from '@workflow/config/load'; +import type { SourcemapMode } from './workflow-config.js'; -export type { SourcemapMode } from '@workflow/config'; +export type { SourcemapMode } from './workflow-config.js'; export const validBuildTargets = [ 'standalone', @@ -42,7 +41,8 @@ export interface BaseBuilderConfig { workflowManifestPath?: string; - workflowConfig?: LoadedWorkflowConfig; + /** @internal Resolved World provider module from workflow.config.ts. */ + worldModule?: string; // Optional prefix for debug files (e.g., "_" for Astro to ignore them) debugFilePrefix?: string; @@ -86,8 +86,7 @@ export interface BaseBuilderConfig { * them out of the function bundle. * * Can also be set via the `WORKFLOW_SOURCEMAP` environment variable; - * an explicit builder option wins over the env var, which wins over - * workflow.config.ts and the default. + * an explicit builder option wins over the env var and the default. */ sourcemap?: SourcemapMode; } @@ -140,7 +139,7 @@ export interface NestConfig extends BaseBuilderConfig { /** * Discriminated union of all builder configuration types. */ -export type BuilderConfig = +export type WorkflowConfig = | StandaloneConfig | VercelBuildOutputConfig | NextConfig @@ -148,8 +147,6 @@ export type BuilderConfig = | SvelteKitConfig | AstroConfig; -export type WorkflowConfig = BuilderConfig; - export function isValidBuildTarget( target: string | undefined ): target is BuildTarget { diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts index 4863554633..ea21569a6c 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, rm, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { BaseBuilder } from './base-builder.js'; -import { createWorkflowQueueTrigger } from './constants.js'; +import { WORKFLOW_QUEUE_TRIGGER } from './constants.js'; export class VercelBuildOutputAPIBuilder extends BaseBuilder { async build(): Promise { @@ -38,9 +38,7 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder { // serves no purpose without maps. shouldAddSourcemapSupport: this.sourcemapsEnabled, maxDuration: 'max', - experimentalTriggers: [ - createWorkflowQueueTrigger({ namespace: this.queueNamespace }), - ], + experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER], runtime: this.config.runtime, }); @@ -60,9 +58,11 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder { // Expose manifest as a static file when WORKFLOW_PUBLIC_MANIFEST=1. // Vercel Build Output API serves static files from .vercel/output/static/ - const staticManifestDir = join(outputDir, 'static/.well-known/workflow/v1'); - await rm(join(staticManifestDir, 'manifest.json'), { force: true }); if (this.shouldExposePublicManifest && manifestJson) { + const staticManifestDir = join( + outputDir, + 'static/.well-known/workflow/v1' + ); await mkdir(staticManifestDir, { recursive: true }); if (process.env.VERCEL_DEPLOYMENT_ID === undefined) { await writeFile(join(staticManifestDir, '.gitignore'), '*'); diff --git a/packages/builders/src/workflow-config.cts b/packages/builders/src/workflow-config.cts new file mode 100644 index 0000000000..1f2a7cdb4d --- /dev/null +++ b/packages/builders/src/workflow-config.cts @@ -0,0 +1,9 @@ +import type { LoadedWorkflowConfig } from './workflow-config.js'; + +export async function loadWorkflowConfig(options: { + cwd: string; + configFile?: string; +}): Promise { + const loader = await import('./workflow-config.js'); + return loader.loadWorkflowConfig(options); +} diff --git a/packages/builders/src/workflow-config.ts b/packages/builders/src/workflow-config.ts new file mode 100644 index 0000000000..1edd3bd585 --- /dev/null +++ b/packages/builders/src/workflow-config.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import { existsSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, resolve, win32 } from 'node:path'; +import type { WorldProvider } from '@workflow/world'; +import { findUp } from 'find-up'; +import { createJiti } from 'jiti'; +import { z } from 'zod/v4'; + +const sourcemapSchema = z.union([ + z.boolean(), + z.enum(['inline', 'linked', 'external', 'both']), +]); + +const workflowConfigSchema = z.strictObject({ + world: z.string().min(1).optional(), + build: z + .strictObject({ + dirs: z.array(z.string().min(1)).min(1).optional(), + projectRoot: z.string().min(1).optional(), + sourcemap: sourcemapSchema.optional(), + }) + .optional(), +}); + +export type SourcemapMode = z.infer; +export type WorkflowConfig = z.infer; + +export type LoadedWorkflowConfig = { + worldModule: string | undefined; + config: WorkflowConfig; +}; + +export async function loadWorkflowConfig({ + cwd, + configFile, +}: { + cwd: string; + configFile?: string; +}): Promise { + let path = await findUp( + ['workflow.config.ts', 'workflow.config.mjs', 'workflow.config.js'], + { cwd } + ); + if (configFile) { + path = isAbsolute(configFile) ? configFile : resolve(cwd, configFile); + assert( + existsSync(path) && statSync(path).isFile(), + `Workflow config file not found: ${path}` + ); + } + + let rawConfig: unknown = {}; + if (path) { + const module = await createJiti(import.meta.url, { + interopDefault: false, + }).import<{ default: unknown }>(path); + rawConfig = module.default; + } + const config = workflowConfigSchema.parse(rawConfig); + if (!config.world) return { worldModule: undefined, config }; + + assert(path); + const world = config.world; + assert( + !isAbsolute(world) && + !win32.isAbsolute(world) && + !/^[a-z][a-z\d+.-]*:/i.test(world), + `World module must be a relative path or package specifier: ${world}` + ); + if (!world.startsWith('.')) { + return { worldModule: createJiti(path).esmResolve(world), config }; + } + + const worldModule = resolve(dirname(path), world); + assert( + existsSync(worldModule) && statSync(worldModule).isFile(), + `World module not found: ${world}` + ); + return { worldModule, config }; +} + +export type WorkflowConfigLoader = typeof loadWorkflowConfig; + +export async function loadWorldProvider( + worldModule: string +): Promise { + const module = await createJiti(import.meta.url, { + interopDefault: false, + }).import<{ default: WorldProvider }>(worldModule); + return module.default; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 08ed24b2b0..cf54b68e17 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -45,7 +45,6 @@ "@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 6b83f90913..ace29f4055 100644 --- a/packages/cli/src/base.ts +++ b/packages/cli/src/base.ts @@ -1,5 +1,5 @@ import { Command } from '@oclif/core'; -import { closeWorld } from '@workflow/core/runtime'; +import { getWorld } from '@workflow/core/runtime'; async function flushStream(stream: NodeJS.WriteStream): Promise { if ( @@ -37,7 +37,8 @@ export abstract class BaseCommand extends Command { */ async finally(err: Error | undefined): Promise { try { - await closeWorld(); + const world = await getWorld(); + await world.close?.(); } catch (closeErr) { this.warn( `Failed to close world: ${closeErr instanceof Error ? closeErr.message : String(closeErr)}` diff --git a/packages/cli/src/commands/health.ts b/packages/cli/src/commands/health.ts index 7c9ce019c6..23f95e8e70 100644 --- a/packages/cli/src/commands/health.ts +++ b/packages/cli/src/commands/health.ts @@ -162,7 +162,7 @@ async function verifyLocalServerAccessible( ); } -function isLocalBackend(backend: string | undefined): boolean { +function isLocalBackend(backend: string): boolean { return backend === 'local' || backend === '@workflow/world-local'; } diff --git a/packages/cli/src/lib/config/workflow-config.test.ts b/packages/cli/src/lib/config/workflow-config.test.ts deleted file mode 100644 index d4ebfdb07b..0000000000 --- a/packages/cli/src/lib/config/workflow-config.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -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({ buildTarget: 'standalone' })).dirs - ).toEqual(['.']); - - writeFileSync( - join(workingDir, 'workflow.config.ts'), - `export default { build: { dirs: ['jobs'] } };` - ); - expect( - (await getWorkflowConfig({ buildTarget: 'standalone' })).dirs - ).toEqual(['jobs']); - }); -}); diff --git a/packages/cli/src/lib/config/workflow-config.ts b/packages/cli/src/lib/config/workflow-config.ts index 1175d27145..1e50e3af76 100644 --- a/packages/cli/src/lib/config/workflow-config.ts +++ b/packages/cli/src/lib/config/workflow-config.ts @@ -2,7 +2,7 @@ import { resolve } from 'node:path'; import { type LoadedWorkflowConfig, loadWorkflowConfig, -} from '@workflow/config/load'; +} from '@workflow/builders/workflow-config'; import { config as loadDotEnv } from 'dotenv'; import type { BuildTarget, WorkflowConfig } from './types.js'; @@ -13,9 +13,7 @@ type CliBuildTarget = Extract< export function resolveWorkflowCwd(): string { const raw = process.env.WORKFLOW_OBSERVABILITY_CWD; - if (!raw) { - return process.cwd(); - } + if (!raw) return process.cwd(); // Allow relative paths; resolve relative to the current process.cwd() // (i.e. where the CLI was invoked). return resolve(process.cwd(), raw); @@ -44,7 +42,11 @@ export const getWorkflowConfig = async (options: { fileConfig.build?.dirs ?? (buildTarget === 'standalone' ? ['.'] : ['./workflows']), workingDir, - workflowConfig: loadedConfig, + projectRoot: fileConfig.build?.projectRoot, + worldModule: loadedConfig.worldModule, + sourcemap: process.env.WORKFLOW_SOURCEMAP + ? undefined + : fileConfig.build?.sourcemap, workflowManifestPath: workflowManifest, }; if (buildTarget === 'standalone') { diff --git a/packages/cli/src/lib/inspect/flags.test.ts b/packages/cli/src/lib/inspect/flags.test.ts deleted file mode 100644 index 988ef9129d..0000000000 --- a/packages/cli/src/lib/inspect/flags.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Parser } from '@oclif/core'; -import { expect, it } from 'vitest'; -import { cliFlags } from './flags.js'; - -it('parses without an explicit backend', async () => { - const { flags } = await Parser.parse([], { flags: cliFlags }); - - expect(flags.backend).toBeUndefined(); - expect(flags.authToken).toBeUndefined(); - expect(flags.project).toBeUndefined(); - expect(flags.team).toBeUndefined(); -}); diff --git a/packages/cli/src/lib/inspect/setup.test.ts b/packages/cli/src/lib/inspect/setup.test.ts index 01c0489134..76ba35d6ab 100644 --- a/packages/cli/src/lib/inspect/setup.test.ts +++ b/packages/cli/src/lib/inspect/setup.test.ts @@ -1,11 +1,10 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { setRuntimeWorkflowConfig } from '@workflow/config/runtime'; -import { closeWorld } from '@workflow/core/runtime'; +import { setWorld } from '@workflow/core/runtime'; import type { World } from '@workflow/world'; -import { resolveQueueNamespace } from '@workflow/world/queue.js'; import { afterEach, expect, it, vi } from 'vitest'; +import { getWorkflowConfig } from '../config/workflow-config.js'; import { setupCliWorld } from './setup.js'; vi.mock('../update-check.js', () => ({ @@ -13,24 +12,12 @@ vi.mock('../update-check.js', () => ({ })); const project = mkdtempSync(join(tmpdir(), 'workflow-cli-world-')); -const originalCwd = process.env.WORKFLOW_OBSERVABILITY_CWD; -const originalTarget = process.env.WORKFLOW_TARGET_WORLD; -afterEach(async () => { - await closeWorld(); - setRuntimeWorkflowConfig(undefined); +afterEach(() => { + setWorld(undefined); + vi.unstubAllEnvs(); delete (globalThis as { __workflowCliWorldStarted?: boolean }) .__workflowCliWorldStarted; - if (originalCwd === undefined) { - delete process.env.WORKFLOW_OBSERVABILITY_CWD; - } else { - process.env.WORKFLOW_OBSERVABILITY_CWD = originalCwd; - } - if (originalTarget === undefined) { - delete process.env.WORKFLOW_TARGET_WORLD; - } else { - process.env.WORKFLOW_TARGET_WORLD = originalTarget; - } rmSync(project, { recursive: true, force: true }); }); @@ -39,7 +26,7 @@ it('uses the configured World instead of the implicit local default', async () = join(project, 'workflow.config.ts'), `export default { world: './workflow.world.mjs', - queue: { namespace: 'configured' } + build: { dirs: ['jobs'] } };` ); writeFileSync( @@ -49,17 +36,18 @@ it('uses the configured World instead of the implicit local default', async () = start() { globalThis.__workflowCliWorldStarted = true; } });` ); - process.env.WORKFLOW_OBSERVABILITY_CWD = project; + vi.stubEnv('WORKFLOW_OBSERVABILITY_CWD', project); + vi.stubEnv('WORKFLOW_TARGET_WORLD', ''); delete process.env.WORKFLOW_TARGET_WORLD; + const config = await getWorkflowConfig({ buildTarget: 'standalone' }); + expect(config.dirs).toEqual(['jobs']); + expect(config.worldModule).toBe(join(project, 'workflow.world.mjs')); + const world = await setupCliWorld( { json: true, verbose: false, - env: 'production', - authToken: '', - project: '', - team: '', }, 'test' ); @@ -69,6 +57,5 @@ it('uses the configured World instead of the implicit local default', async () = (globalThis as { __workflowCliWorldStarted?: boolean }) .__workflowCliWorldStarted ).toBe(true); - expect(process.env.WORKFLOW_TARGET_WORLD).toBeUndefined(); - expect(resolveQueueNamespace()).toBe('configured'); + expect(process.env.WORKFLOW_TARGET_WORLD).toBe('configured'); }); diff --git a/packages/cli/src/lib/inspect/setup.ts b/packages/cli/src/lib/inspect/setup.ts index fc35562136..6df8276faf 100644 --- a/packages/cli/src/lib/inspect/setup.ts +++ b/packages/cli/src/lib/inspect/setup.ts @@ -1,5 +1,4 @@ -import { createRuntimeWorkflowConfig } from '@workflow/config/load'; -import { setRuntimeWorkflowConfig } from '@workflow/config/runtime'; +import { loadWorldProvider } from '@workflow/builders/workflow-config'; import { getWorld, setWorld } from '@workflow/core/runtime'; import { isVercelWorldTarget } from '@workflow/utils'; import { createVercelWorld } from '@workflow/world-vercel'; @@ -34,13 +33,16 @@ export const setupCliWorld = async ( setVerboseMode(Boolean(flags.verbose)); const loadedConfig = await loadProjectWorkflowConfig(); - setRuntimeWorkflowConfig(createRuntimeWorkflowConfig(loadedConfig)); + const configured = + !flags.backend && + !process.env.WORKFLOW_TARGET_WORLD && + !!loadedConfig.worldModule; const backend = flags.backend ?? process.env.WORKFLOW_TARGET_WORLD ?? - (loadedConfig.config.world - ? undefined + (configured + ? 'configured' : process.env.VERCEL_DEPLOYMENT_ID ? 'vercel' : 'local'); @@ -90,8 +92,8 @@ export const setupCliWorld = async ( ); writeEnvVars({ DEBUG: flags.verbose ? '1' : '', + WORKFLOW_TARGET_WORLD: backend, }); - if (backend) writeEnvVars({ WORKFLOW_TARGET_WORLD: backend }); let vercelEnvVars: VercelEnvVars | undefined; if (backend && isVercelWorldTarget(backend)) { @@ -147,5 +149,13 @@ export const setupCliWorld = async ( return world; } + if (configured && loadedConfig.worldModule) { + const worldProvider = await loadWorldProvider(loadedConfig.worldModule); + const world = await worldProvider(); + await world.start?.(); + setWorld(world); + return world; + } + return getWorld(); }; diff --git a/packages/config/README.md b/packages/config/README.md deleted file mode 100644 index a40c97714f..0000000000 --- a/packages/config/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# @workflow/config - -Typed, shared configuration for Workflow SDK. - -The public API is exposed through `workflow/config`. - -See the [configuration guide](https://workflow-sdk.dev/v5/docs/foundations/configuration) -for the available settings. diff --git a/packages/config/package.json b/packages/config/package.json deleted file mode 100644 index 112cd6fa1f..0000000000 --- a/packages/config/package.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "name": "@workflow/config", - "version": "5.0.0-beta.0", - "description": "Typed configuration for Workflow SDK", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "files": [ - "dist" - ], - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./load": { - "import": { - "types": "./dist/load.d.ts", - "default": "./dist/load.js" - }, - "require": { - "types": "./dist/load.d.cts", - "default": "./dist/load.cjs" - } - }, - "./runtime": { - "types": "./dist/runtime.d.ts", - "default": "./dist/runtime.js" - }, - "./runtime-binding": { - "types": "./dist/runtime-binding.d.ts", - "default": "./dist/runtime-binding.js" - } - }, - "publishConfig": { - "access": "public" - }, - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "https://github.com/vercel/workflow.git", - "directory": "packages/config" - }, - "scripts": { - "build": "tsc", - "clean": "tsc --build --clean && rm -rf dist", - "dev": "tsc --watch", - "test": "vitest run src", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@workflow/world": "workspace:*", - "find-up": "7.0.0", - "jiti": "2.7.0", - "zod": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:", - "@workflow/tsconfig": "workspace:*", - "vitest": "catalog:" - } -} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts deleted file mode 100644 index 1d2a8cf617..0000000000 --- a/packages/config/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type { WorldProvider } from '@workflow/world'; -export type { SourcemapMode, WorkflowConfig } from './schema.js'; -export type WorkflowConfigLoader = - typeof import('./load.js').loadWorkflowConfig; diff --git a/packages/config/src/load.cts b/packages/config/src/load.cts deleted file mode 100644 index 44d877d0bf..0000000000 --- a/packages/config/src/load.cts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 0aa8d69ad8..0000000000 --- a/packages/config/src/load.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -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 { - getRuntimeWorkflowConfig, - setRuntimeWorkflowConfig, -} from './runtime.js'; -import { WorkflowConfigSchema } from './schema.js'; - -const tempDirs: string[] = []; - -function createProject(files: Record): string { - const project = mkdtempSync(join(tmpdir(), 'workflow-config-')); - tempDirs.push(project); - - for (const [file, contents] of Object.entries(files)) { - const path = join(project, file); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, contents, 'utf8'); - } - - return project; -} - -afterEach(() => { - setRuntimeWorkflowConfig(undefined); - delete process.env.WORKFLOW_QUEUE_NAMESPACE; - delete (globalThis as { __workflowWorldImports?: number }) - .__workflowWorldImports; - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -describe('loadWorkflowConfig', () => { - it('returns empty config when no config file exists', async () => { - const project = createProject({}); - - await expect(loadWorkflowConfig({ cwd: project })).resolves.toEqual({ - path: undefined, - runtimePath: undefined, - config: {}, - }); - }); - - it('allows config without a World provider', async () => { - const project = createProject({ - 'workflow.config.ts': `export default { queue: { namespace: 'app' } };`, - }); - const loaded = await loadWorkflowConfig({ cwd: project }); - await import(pathToFileURL(loaded.runtimePath as string).href); - - expect(getRuntimeWorkflowConfig()).toEqual({ - world: undefined, - queue: { namespace: 'app' }, - }); - }); - - it('preserves a build-time queue namespace without a config file', async () => { - process.env.WORKFLOW_QUEUE_NAMESPACE = 'environment'; - const project = createProject({}); - - const loaded = await loadWorkflowConfig({ cwd: project }); - await import(pathToFileURL(loaded.runtimePath as string).href); - - expect(loaded.path).toBeUndefined(); - expect(loaded.config.queue).toEqual({ namespace: 'environment' }); - expect(getRuntimeWorkflowConfig()?.queue).toEqual({ - namespace: 'environment', - }); - }); - - it('loads the nearest TypeScript config without merging parents', async () => { - const project = createProject({ - 'workflow.config.ts': `export default { build: { dirs: ['parent'] } };`, - 'apps/web/workflow.config.ts': `export default { - build: { dirs: ['app'], sourcemap: false }, - integration: { type: 'next' } - };`, - }); - const app = join(project, 'apps', 'web'); - - const loaded = await loadWorkflowConfig({ - cwd: app, - integration: 'next', - }); - - expect(loaded.path).toBe(join(app, 'workflow.config.ts')); - expect(loaded.runtimePath).toBeUndefined(); - expect(loaded.config).toEqual({ - build: { dirs: ['app'], sourcemap: false }, - integration: { type: 'next' }, - }); - }); - - it('generates a runtime binding without loading the World module', async () => { - const project = createProject({ - 'workflow.world.ts': `throw new Error('must stay lazy');`, - 'workflow.config.ts': `export default { - world: './workflow.world.ts', - build: { dirs: ['jobs'] }, - queue: { namespace: 'app' } - };`, - }); - - const loaded = await loadWorkflowConfig({ cwd: project }); - const runtimeSource = readFileSync(loaded.runtimePath as string, 'utf8'); - - expect(loaded.config.world).toBe('./workflow.world.ts'); - expect(runtimeSource).toContain('workflow.world.ts'); - expect(runtimeSource).toContain('queue: {"namespace":"app"}'); - expect(runtimeSource).not.toContain("dirs: ['jobs']"); - }); - - it('loads the World module only when its provider runs', async () => { - const globals = globalThis as typeof globalThis & { - __workflowWorldImports?: number; - }; - const project = createProject({ - 'workflow.world.mjs': ` -globalThis.__workflowWorldImports = (globalThis.__workflowWorldImports ?? 0) + 1; -export default () => ({}); -`, - 'workflow.config.ts': `export default { world: './workflow.world.mjs' };`, - }); - const loaded = await loadWorkflowConfig({ cwd: project }); - - await import(pathToFileURL(loaded.runtimePath as string).href); - expect(globals.__workflowWorldImports).toBeUndefined(); - - await getRuntimeWorkflowConfig()?.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 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.each([ - 'file:///tmp/world.mjs', - 'data:text/javascript,export default () => ({})', - 'node:fs', - 'C:\\world.mjs', - ])('rejects non-package World specifier %s', async (world) => { - const project = createProject({ - 'workflow.config.ts': `export default { world: ${JSON.stringify(world)} };`, - }); - - 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'] } };`, - 'workflow.config.mjs': `export default { build: { dirs: ['javascript'] } };`, - }); - - await expect(loadWorkflowConfig({ cwd: project })).rejects.toThrow( - 'Multiple Workflow config files found' - ); - }); - - it('rejects unsupported filenames instead of silently using a parent', async () => { - const project = createProject({ - 'app/workflow.config.json': JSON.stringify({ - build: { dirs: ['workflows'] }, - }), - }); - const app = join(project, 'app'); - - await expect(loadWorkflowConfig({ cwd: app })).rejects.toThrow( - 'Unsupported Workflow config file' - ); - }); - - it('rejects integration config for another platform', async () => { - const project = createProject({ - 'workflow.config.ts': `export default { integration: { type: 'nitro' } };`, - }); - - await expect( - loadWorkflowConfig({ - cwd: project, - integration: 'next', - }) - ).rejects.toThrow('configures "nitro" but was loaded by "next"'); - }); - - it('rejects top-level config functions and unknown keys', async () => { - const project = createProject({ - 'workflow.config.ts': `export default () => ({ build: { dirs: ['workflows'] } });`, - }); - - await expect(loadWorkflowConfig({ cwd: project })).rejects.toThrow( - 'must default-export a static object' - ); - - const promiseProject = createProject({ - 'workflow.config.ts': `export default Promise.resolve({ build: { dirs: ['workflows'] } });`, - }); - await expect(loadWorkflowConfig({ cwd: promiseProject })).rejects.toThrow( - 'must default-export a static object' - ); - - expect(() => WorkflowConfigSchema.parse({ unknown: true })).toThrow( - 'Unrecognized key' - ); - }); - - it('rejects an empty queue section', () => { - expect(() => WorkflowConfigSchema.parse({ queue: {} })).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 deleted file mode 100644 index e0174a676f..0000000000 --- a/packages/config/src/load.ts +++ /dev/null @@ -1,191 +0,0 @@ -import assert from 'node:assert/strict'; -import { - existsSync, - mkdirSync, - readdirSync, - statSync, - writeFileSync, -} from 'node:fs'; -import { - basename, - dirname, - extname, - isAbsolute, - join, - relative, - resolve, - win32, -} from 'node:path'; -import type { WorldProvider } from '@workflow/world'; -import { findUp } from 'find-up'; -import { createJiti } from 'jiti'; -import type { RuntimeWorkflowConfig } from './runtime.js'; -import { - type WorkflowConfig, - WorkflowConfigSchema, - type WorkflowIntegrationType, -} from './schema.js'; - -const WORKFLOW_CONFIG_FILES = [ - 'workflow.config.ts', - 'workflow.config.mjs', - 'workflow.config.js', -] as const; - -export type LoadWorkflowConfigOptions = { - cwd: string; - configFile?: string; - integration?: WorkflowIntegrationType; -}; - -export type LoadedWorkflowConfig = { - path: string | undefined; - runtimePath: string | undefined; - config: WorkflowConfig; -}; - -async function discoverWorkflowConfig({ - cwd, - configFile, -}: Pick): Promise< - string | undefined -> { - if (configFile) { - const path = isAbsolute(configFile) ? configFile : resolve(cwd, configFile); - assert( - ['.ts', '.mjs', '.js'].includes(extname(path)), - `Unsupported Workflow config extension "${extname(path)}".` - ); - assert( - existsSync(path) && statSync(path).isFile(), - `Workflow config file not found: ${path}` - ); - return path; - } - - return findUp( - (directory) => { - const configs = readdirSync(directory).filter((file) => - file.startsWith('workflow.config.') - ); - assert( - configs.length <= 1, - `Multiple Workflow config files found in ${directory}: ${configs.join(', ')}` - ); - - const config = configs[0]; - if (!config) return; - - assert( - WORKFLOW_CONFIG_FILES.some((file) => file === config), - `Unsupported Workflow config file "${config}".` - ); - return join(directory, config); - }, - { cwd } - ); -} - -async function readWorkflowConfig( - path: string, - integration: WorkflowIntegrationType | undefined -): Promise { - 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); - if (!integration || !config.integration) return config; - - assert( - config.integration.type === integration, - `${basename(path)} configures "${config.integration.type}" but was loaded by "${integration}".` - ); - return config; -} - -export async function loadWorkflowConfig( - options: LoadWorkflowConfigOptions -): Promise { - const path = await discoverWorkflowConfig(options); - let config: WorkflowConfig = path - ? await readWorkflowConfig(path, options.integration) - : {}; - - if (process.env.WORKFLOW_QUEUE_NAMESPACE !== undefined) { - config = WorkflowConfigSchema.parse({ - ...config, - queue: { namespace: process.env.WORKFLOW_QUEUE_NAMESPACE }, - }); - } - - if (!config.world && !config.queue) { - return { path, runtimePath: undefined, config }; - } - - const runtimeDir = join( - path ? dirname(path) : options.cwd, - 'node_modules', - '.cache', - 'workflow' - ); - let world = config.world; - if (world) { - assert(path); - assert( - !isAbsolute(world) && - !win32.isAbsolute(world) && - !/^[a-z][a-z\d+.-]*:/i.test(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(), - `World module not found: ${world}` - ); - world = relative(runtimeDir, worldPath).replaceAll('\\', '/'); - if (!world.startsWith('.')) world = `./${world}`; - } else { - 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};\nglobalThis[Symbol.for('@workflow/config/runtime')] = { world, queue: ${JSON.stringify(config.queue)} };\n` - ); - - return { path, runtimePath, config }; -} - -export function createRuntimeWorkflowConfig({ - path, - config, -}: LoadedWorkflowConfig): RuntimeWorkflowConfig { - if (!config.world) return { queue: config.queue }; - - assert(path); - 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 deleted file mode 100644 index cb0ff5c3b5..0000000000 --- a/packages/config/src/runtime-binding.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/config/src/runtime.ts b/packages/config/src/runtime.ts deleted file mode 100644 index 77aebbe67c..0000000000 --- a/packages/config/src/runtime.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { WorldProvider } from '@workflow/world'; -import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; -import type { WorkflowConfig } from './schema.js'; - -export type RuntimeWorkflowConfig = Pick & { - world?: WorldProvider; -}; - -const RuntimeWorkflowConfigSymbol = Symbol.for('@workflow/config/runtime'); - -const globals = globalThis as typeof globalThis & { - [RuntimeWorkflowConfigSymbol]?: RuntimeWorkflowConfig; -}; - -export function getRuntimeWorkflowConfig(): RuntimeWorkflowConfig | undefined { - return globals[RuntimeWorkflowConfigSymbol]; -} - -export function setRuntimeWorkflowConfig( - config: RuntimeWorkflowConfig | undefined -): void { - globals[RuntimeWorkflowConfigSymbol] = config; - setWorkflowQueueNamespace(config?.queue?.namespace); -} diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts deleted file mode 100644 index e75ed40622..0000000000 --- a/packages/config/src/schema.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { QueueNamespaceSchema } from '@workflow/world/queue.js'; -import { z } from 'zod/v4'; - -const sourcemapSchema = z.union([ - z.boolean(), - z.enum(['inline', 'linked', 'external', 'both']), -]); -export type SourcemapMode = z.infer; - -const integrationSchema = z.discriminatedUnion('type', [ - z.strictObject({ - type: z.literal('next'), - }), - z.strictObject({ - type: z.literal('nitro'), - typescriptPlugin: z.boolean().optional(), - runtime: z.string().min(1).optional(), - }), -]); - -export const WorkflowConfigSchema = z.strictObject({ - world: z.string().min(1).optional(), - build: z - .strictObject({ - dirs: z.array(z.string().min(1)).min(1).optional(), - projectRoot: z.string().min(1).optional(), - externalPackages: z.array(z.string().min(1)).optional(), - sourcemap: sourcemapSchema.optional(), - manifest: z - .strictObject({ - public: z.boolean().optional(), - output: z.string().min(1).optional(), - }) - .optional(), - }) - .optional(), - queue: z - .strictObject({ - namespace: QueueNamespaceSchema, - }) - .optional(), - integration: integrationSchema.optional(), -}); - -export type WorkflowConfig = z.infer; -export type WorkflowIntegrationType = - | NonNullable['type'] - | 'astro' - | 'sveltekit'; diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json deleted file mode 100644 index a78dbf413c..0000000000 --- a/packages/config/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 ffe390227d..3f741982d8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -95,7 +95,6 @@ "@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.test.ts b/packages/core/src/runtime.test.ts index 4d9f40c9c2..ea4dcdbd2d 100644 --- a/packages/core/src/runtime.test.ts +++ b/packages/core/src/runtime.test.ts @@ -8,7 +8,6 @@ import { SPEC_VERSION_CURRENT, type WorkflowRun, } from '@workflow/world'; -import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { registerStepFunction } from './private.js'; import { REPLAY_DIVERGENCE_MAX_RETRIES } from './runtime/constants.js'; @@ -126,57 +125,6 @@ async function runWorkflowHandlerWithEvents( return createdEvents; } -describe('workflowEntrypoint World replacement', () => { - afterEach(() => { - setWorld(undefined); - setWorkflowQueueNamespace(undefined); - }); - - it('resolves the namespace when the handler binds to its World', async () => { - const createQueueHandler = vi.fn(() => async () => new Response()); - setWorld({ - specVersion: SPEC_VERSION_CURRENT, - createQueueHandler, - } as any); - const entrypoint = workflowEntrypoint(''); - - setWorkflowQueueNamespace('app'); - await entrypoint(new Request('https://example.test')); - - expect(createQueueHandler).toHaveBeenCalledWith( - '__app_wkf_workflow_', - expect.any(Function) - ); - }); - - it('rebuilds its queue handler after the World changes', async () => { - const firstHandler = vi.fn(async () => new Response('first')); - const secondHandler = vi.fn(async () => new Response('second')); - const firstCreate = vi.fn(() => firstHandler); - const secondCreate = vi.fn(() => secondHandler); - const entrypoint = workflowEntrypoint(''); - - setWorld({ - specVersion: SPEC_VERSION_CURRENT, - createQueueHandler: firstCreate, - } as any); - expect( - await (await entrypoint(new Request('https://example.test'))).text() - ).toBe('first'); - - setWorld(undefined); - setWorld({ - specVersion: SPEC_VERSION_CURRENT, - createQueueHandler: secondCreate, - } as any); - expect( - await (await entrypoint(new Request('https://example.test'))).text() - ).toBe('second'); - expect(firstCreate).toHaveBeenCalledOnce(); - expect(secondCreate).toHaveBeenCalledOnce(); - }); -}); - describe('workflowEntrypoint replay guards', () => { afterEach(() => { setWorld(undefined); diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 4d4aa59d3b..49b2dd5281 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -49,7 +49,11 @@ import { import { executeStep } from './runtime/step-executor.js'; import { handleSuspension } from './runtime/suspension-handler.js'; import { getWaitContinuationDispatch } from './runtime/wait-continuation.js'; -import { getWorld, type WorldHandlers } from './runtime/world.js'; +import { + getWorld, + getWorldHandlers, + type WorldHandlers, +} from './runtime/world.js'; import { dehydrateRunError } from './serialization.js'; import { remapErrorStack } from './source-map.js'; import { @@ -114,12 +118,10 @@ export { // prevents Turbopack from tracing step-handler.js → get-port.js // filesystem operations into the flow route bundle. export { - closeWorld, createWorld, getWorld, getWorldHandlers, setWorld, - usesConfiguredWorld, } from './runtime/world.js'; function getWorkflowSetupErrorCode(err: unknown): RunErrorCode | null { @@ -290,10 +292,11 @@ export function workflowEntrypoint( const NO_INLINE_REPLAY_AFTER_MS = Number(process.env.WORKFLOW_V2_TIMEOUT_MS) || 120_000; - const handler = (worldHandlers: WorldHandlers) => { - const namespace = resolveQueueNamespace(options?.namespace); - const workflowPrefix = getQueueTopicPrefix('workflow', namespace); - return worldHandlers.createQueueHandler( + const namespace = resolveQueueNamespace(options?.namespace); + const workflowPrefix = getQueueTopicPrefix('workflow', namespace); + + const handler = (worldHandlers: WorldHandlers) => + worldHandlers.createQueueHandler( workflowPrefix, async (message_, metadata) => { // Check if this is a health check message @@ -947,6 +950,7 @@ export function workflowEntrypoint( const encryptionKey = await getEncryptionKey(); // Main replay loop + // biome-ignore lint/correctness/noConstantCondition: intentional loop while (true) { loopIteration++; @@ -2018,10 +2022,8 @@ export function workflowEntrypoint( }); // End withTraceContext } ); - }; let cachedHandler: ((req: Request) => Promise) | undefined; - let cachedWorld: World | undefined; let invocationCount = 0; const entrypointCreatedAt = Date.now(); const routeModuleBodyInitMs = @@ -2031,8 +2033,7 @@ export function workflowEntrypoint( return withHealthCheck(async (req) => { invocationCount += 1; - const world = await getWorld(); - const handlerCached = cachedHandler !== undefined && cachedWorld === world; + const handlerCached = cachedHandler !== undefined; const spanKind = await getSpanKind('SERVER'); return trace( @@ -2054,15 +2055,14 @@ export function workflowEntrypoint( }, }, async (span) => { - if (!cachedHandler || cachedWorld !== world) { + if (!cachedHandler) { cachedHandler = await trace('workflow.route.init', async () => { const worldHandlers = await trace( 'workflow.route.get_world_handlers', - async () => world + async () => getWorldHandlers() ); return handler(worldHandlers); }); - cachedWorld = world; } const response = await cachedHandler(req); diff --git a/packages/core/src/runtime/get-world-lazy.ts b/packages/core/src/runtime/get-world-lazy.ts index e7616ceb73..bebb188c9d 100644 --- a/packages/core/src/runtime/get-world-lazy.ts +++ b/packages/core/src/runtime/get-world-lazy.ts @@ -9,13 +9,14 @@ * * Resolution order, in priority: * - * 1. `globalThis[GetWorldFnKey]` — populated by the module-load side + * 1. `globalThis[WorldCacheKey]` — populated by a successful prior + * `getWorld()` call. This is the steady-state hot path. + * 2. `globalThis[GetWorldFnKey]` — populated by the module-load side * effect at the bottom of `./world.ts`. Fires on every server bundle * that reaches this file via `workflow/api` (which imports * `./world-init.ts` for its side effect; see that file for the full - * rationale). - * 2. The global World cache or pending creation promise when world.ts is not - * present in the bundle. + * rationale). This is the cold-start path for routes that consume + * `start` without any prior workflow run. * 3. Dynamic `import('./world.js')` — last-resort fallback for * environments where neither (1) nor (2) is available (CJS test * runners, scripts that import deeply into `@workflow/core` without @@ -35,17 +36,18 @@ const GetWorldFnKey = Symbol.for('@workflow/world//getWorldFn'); export async function getWorldLazy(): Promise { const g = globalThis as any; - const getWorldFn = g[GetWorldFnKey] as (() => Promise) | undefined; - if (getWorldFn) return getWorldFn(); if (g[WorldCacheKey]) return g[WorldCacheKey]; if (g[WorldCachePromiseKey]) { - const pendingWorld = g[WorldCachePromiseKey]; - const world = await pendingWorld; - if (g[WorldCachePromiseKey] === pendingWorld) { - g[WorldCacheKey] = world; - } - return world; + g[WorldCacheKey] = await g[WorldCachePromiseKey]; + return g[WorldCacheKey]; } + // If world.ts is statically present in this bundle, it has registered + // getWorld on globalThis at module load. Prefer that over the dynamic + // import fallback, which doesn't survive Next.js inlining get-world-lazy + // into a route bundle (the relative './world.js' resolves against the + // bundled location, where no sibling world.js exists). + const getWorldFn = g[GetWorldFnKey] as (() => Promise) | undefined; + if (getWorldFn) return getWorldFn(); // Last resort: dynamic import for environments where world.ts wasn't // bundled but is reachable as a sibling module on disk. The specifier is // built at runtime so esbuild can't trace it into the step bundle. diff --git a/packages/core/src/runtime/step-handler.test.ts b/packages/core/src/runtime/step-handler.test.ts index 47bf568a53..dc5a4bd670 100644 --- a/packages/core/src/runtime/step-handler.test.ts +++ b/packages/core/src/runtime/step-handler.test.ts @@ -1,11 +1,9 @@ -import assert from 'node:assert/strict'; import { EntityConflictError, FatalError, ThrottleError, WorkflowWorldError, } from '@workflow/errors'; -import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; import { afterEach, beforeAll, @@ -19,7 +17,6 @@ import { // Use vi.hoisted so these are available in mock factories const { capturedHandlerRef, - capturedPrefixRef, mockEventsCreate, mockQueue, mockRuntimeLogger, @@ -34,7 +31,6 @@ const { capturedHandlerRef: { current: null as null | ((...args: unknown[]) => Promise), }, - capturedPrefixRef: { current: '' }, mockEventsCreate: vi.fn(), mockQueue: vi.fn().mockResolvedValue({ messageId: 'msg_test' }), mockRuntimeLogger: (() => { @@ -72,19 +68,21 @@ vi.mock('@vercel/functions', () => ({ // Mock the world module - createQueueHandler captures the handler vi.mock('./world.js', () => ({ getWorld: vi.fn(async () => ({ + events: { create: mockEventsCreate }, + queue: mockQueue, + getEncryptionKeyForRun: vi.fn().mockResolvedValue(undefined), + })), + getWorldHandlers: vi.fn(async () => ({ createQueueHandler: vi.fn( ( - prefix: string, + _prefix: string, handler: (...args: unknown[]) => Promise ): ((req: Request) => Promise) => { - capturedPrefixRef.current = prefix; capturedHandlerRef.current = handler; + // Return a mock request handler return vi.fn() as unknown as (req: Request) => Promise; } ), - events: { create: mockEventsCreate }, - queue: mockQueue, - getEncryptionKeyForRun: vi.fn().mockResolvedValue(undefined), })), })); @@ -194,7 +192,8 @@ import { import { MAX_QUEUE_DELIVERIES } from './constants.js'; import { executeStep } from './step-executor.js'; // Import the module AFTER all mocks are set up -// Call stepEntrypoint to trigger createQueueHandler and populate capturedHandlerRef. +// Since getWorldHandlers is now async, we need to call stepEntrypoint +// to trigger createQueueHandler and populate capturedHandlerRef import { stepEntrypoint } from './step-handler.js'; import { getWorld } from './world.js'; @@ -234,20 +233,9 @@ function createMessage(overrides: Record = {}) { }; } -describe('step-handler namespace', () => { - afterEach(() => setWorkflowQueueNamespace(undefined)); - - it('resolves the namespace when the handler binds to its World', async () => { - setWorkflowQueueNamespace('app'); - await stepEntrypoint(new Request('http://localhost')); - - expect(capturedPrefixRef.current).toBe('__app_wkf_step_'); - }); -}); - describe('step-handler 409 handling', () => { // Trigger the lazy handler initialization by calling stepEntrypoint once. - // This gets the World and captures the queue handler. + // This invokes getWorldHandlers() which calls createQueueHandler and captures the handler. beforeAll(async () => { await stepEntrypoint(new Request('http://localhost')); }); @@ -295,6 +283,7 @@ describe('step-handler 409 handling', () => { describe('step_completed 409', () => { it('should warn and return when step_completed gets a 409', async () => { // step_started succeeds, step function succeeds, step_completed returns 409 + let callCount = 0; mockEventsCreate.mockImplementation( (_runId: string, event: { eventType: string }) => { if (event.eventType === 'step_started') { @@ -310,6 +299,7 @@ describe('step-handler 409 handling', () => { }); } if (event.eventType === 'step_completed') { + callCount++; return Promise.reject( new EntityConflictError( 'Cannot complete step because it is already completed' @@ -546,8 +536,8 @@ describe('step-handler 409 handling', () => { ([, event]: [string, { eventType: string }]) => event.eventType === 'step_started' ); - assert(startedCall); - expect(startedCall[2]).toEqual( + expect(startedCall).toBeDefined(); + expect(startedCall![2]).toEqual( expect.objectContaining({ requestId: 'iad1::req-abc' }) ); }); @@ -562,8 +552,8 @@ describe('step-handler 409 handling', () => { ([, event]: [string, { eventType: string }]) => event.eventType === 'step_completed' ); - assert(completedCall); - expect(completedCall[2]).toEqual( + expect(completedCall).toBeDefined(); + expect(completedCall![2]).toEqual( expect.objectContaining({ requestId: 'iad1::req-abc' }) ); }); @@ -575,8 +565,8 @@ describe('step-handler 409 handling', () => { ([, event]: [string, { eventType: string }]) => event.eventType === 'step_started' ); - assert(startedCall); - expect(startedCall[2]).toEqual( + expect(startedCall).toBeDefined(); + expect(startedCall![2]).toEqual( expect.objectContaining({ requestId: undefined }) ); }); @@ -657,7 +647,7 @@ describe('step-handler max deliveries', () => { }); it('should not trigger max deliveries check when under limit', async () => { - await capturedHandler(createMessage(), { + const result = await capturedHandler(createMessage(), { ...createMetadata('myStep'), attempt: MAX_QUEUE_DELIVERIES, }); @@ -1223,7 +1213,7 @@ describe('executeStep optimistic inline start', () => { it('sends step_started carrying the input and completes (when enabled)', async () => { mockEventsCreate .mockReset() - .mockImplementation((_runId: string, _event: { eventType: string }) => + .mockImplementation((_runId: string, event: { eventType: string }) => Promise.resolve({ event: {} }) ); diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index fcab674d87..2a8ba2631f 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -18,7 +18,6 @@ import { SPEC_VERSION_SUPPORTS_COMPRESSION, type Step, StepInvokePayloadSchema, - type World, } from '@workflow/world'; import { describeError } from '../describe-error.js'; import { runtimeLogger, stepLogger } from '../logger.js'; @@ -59,7 +58,7 @@ import { withHealthCheck, } from './helpers.js'; import { safeWaitUntil } from './wait-until.js'; -import { getWorld, type WorldHandlers } from './world.js'; +import { getWorld, getWorldHandlers, type WorldHandlers } from './world.js'; const DEFAULT_STEP_MAX_RETRIES = 3; @@ -1154,19 +1153,18 @@ function createStepHandler(namespace?: string) { }); } +const stepHandler = createStepHandler(); + /** * A single route that handles any step execution request and routes to the * appropriate step function. We may eventually want to create different bundles * for each step, this is temporary. */ let cachedStepHandler: ((req: Request) => Promise) | undefined; -let cachedWorld: World | undefined; export const stepEntrypoint: (req: Request) => Promise = /* @__PURE__ */ withHealthCheck(async (req) => { - const world = await getWorld(); - if (!cachedStepHandler || cachedWorld !== world) { - cachedStepHandler = createStepHandler()(world); - cachedWorld = world; + if (!cachedStepHandler) { + cachedStepHandler = stepHandler(await getWorldHandlers()); } return cachedStepHandler(req); }); diff --git a/packages/core/src/runtime/world-config.test.ts b/packages/core/src/runtime/world-config.test.ts deleted file mode 100644 index a335fdd190..0000000000 --- a/packages/core/src/runtime/world-config.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { setRuntimeWorkflowConfig } from '@workflow/config/runtime'; -import type { World } from '@workflow/world'; -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, - setWorld, - usesConfiguredWorld, -} from './world.js'; - -const targetWorld = process.env.WORKFLOW_TARGET_WORLD; - -afterEach(async () => { - await closeWorld(); - setRuntimeWorkflowConfig(undefined); - if (targetWorld === undefined) { - delete process.env.WORKFLOW_TARGET_WORLD; - } else { - process.env.WORKFLOW_TARGET_WORLD = targetWorld; - } -}); - -describe('configured World', () => { - it('creates, starts, shares, and closes one lazy World', async () => { - delete process.env.WORKFLOW_TARGET_WORLD; - const start = vi.fn(async () => {}); - const close = vi.fn(async () => {}); - const world = { - createQueueHandler: vi.fn(), - specVersion: 4, - start, - close, - } as unknown as World; - const create = vi.fn(() => world); - - setRuntimeWorkflowConfig({ - world: create, - queue: { namespace: 'app' }, - }); - - expect(resolveQueueNamespace()).toBe('app'); - expect(create).not.toHaveBeenCalled(); - const [resolved, handlers] = await Promise.all([ - getWorld(), - getWorldHandlers(), - ]); - - expect(resolved).toBe(world); - expect(handlers.specVersion).toBe(4); - expect(create).toHaveBeenCalledOnce(); - expect(start).toHaveBeenCalledOnce(); - - await closeWorld(); - expect(close).toHaveBeenCalledOnce(); - }); - - 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 }); - - expect(usesConfiguredWorld()).toBe(true); - await expect(getWorld()).rejects.toThrow('startup failed'); - expect(firstClose).toHaveBeenCalledOnce(); - await expect(getWorld()).resolves.toBe(second); - expect(create).toHaveBeenCalledTimes(2); - }); - - it('requires failed startup cleanup before creating a replacement', async () => { - delete process.env.WORKFLOW_TARGET_WORLD; - const firstClose = vi - .fn() - .mockRejectedValueOnce(new Error('close failed')) - .mockResolvedValueOnce(undefined); - 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( - 'World startup and cleanup failed.' - ); - expect(create).toHaveBeenCalledOnce(); - await closeWorld(); - expect(firstClose).toHaveBeenCalledTimes(2); - await expect(getWorld()).resolves.toBe(second); - }); - - it('rejects a configured provider that returns no World', async () => { - delete process.env.WORKFLOW_TARGET_WORLD; - setRuntimeWorkflowConfig({ world: () => undefined as never }); - - await expect(getWorld()).rejects.toThrow( - 'Configured World provider must return a World.' - ); - }); - - it('requires managed Worlds to be closed before reset', async () => { - delete process.env.WORKFLOW_TARGET_WORLD; - const close = vi.fn(async () => {}); - setRuntimeWorkflowConfig({ - world: () => - ({ - start: vi.fn(async () => {}), - close, - }) as unknown as World, - }); - - await getWorld(); - expect(() => setWorld(undefined)).toThrow( - 'Call await closeWorld() before replacing a managed World.' - ); - await closeWorld(); - expect(close).toHaveBeenCalledOnce(); - expect(() => setWorld(undefined)).not.toThrow(); - }); - - 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 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); - expect(create).toHaveBeenCalledTimes(2); - }); - - it('waits for cleanup before starting a replacement World', async () => { - delete process.env.WORKFLOW_TARGET_WORLD; - let finishClose!: () => void; - const closing = new Promise((resolve) => { - finishClose = resolve; - }); - const first = { - start: vi.fn(async () => {}), - close: vi.fn(() => closing), - } 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 getWorld(); - const close = closeWorld(); - const replacement = getWorld(); - const replacementAfterConcurrentClose = closeWorld().then(() => getWorld()); - expect(() => setWorld(second)).toThrow( - 'Cannot replace a World while it is closing.' - ); - expect(create).toHaveBeenCalledOnce(); - - finishClose(); - await close; - await expect(replacement).resolves.toBe(second); - await expect(replacementAfterConcurrentClose).resolves.toBe(second); - expect(create).toHaveBeenCalledTimes(2); - }); - - it('keeps a World cached when cleanup fails', async () => { - delete process.env.WORKFLOW_TARGET_WORLD; - const world = { - start: vi.fn(async () => {}), - close: vi - .fn() - .mockRejectedValueOnce(new Error('close failed')) - .mockResolvedValueOnce(undefined), - } as unknown as World; - const create = vi.fn(() => world); - setRuntimeWorkflowConfig({ world: create }); - - await getWorld(); - await expect(closeWorld()).rejects.toThrow('close failed'); - await expect(getWorld()).resolves.toBe(world); - expect(create).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index 655b08110d..bcd1767e3d 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -1,14 +1,11 @@ -import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; -import { getRuntimeWorkflowConfig } from '@workflow/config/runtime'; -import '@workflow/config/runtime-binding'; import { isVercelWorldTarget, resolveWorkflowTargetWorld, } from '@workflow/utils'; import type { World } from '@workflow/world'; -import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; +import configuredWorldProvider from '@workflow/world/provider'; import { createLocalWorld } from '@workflow/world-local'; import { createVercelWorld } from '@workflow/world-vercel'; @@ -18,37 +15,31 @@ 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 WorldLifecycleKey = Symbol.for('@workflow/world//lifecycle'); - -type WorldLifecycle = - | { status: 'managed' } - | { status: 'cleanup'; world: World } - | { status: 'closing'; managed: boolean; promise: Promise }; +const StubbedWorldCachePromise = Symbol.for( + '@workflow/world//stubbedCachePromise' +); const globalSymbols: typeof globalThis & { [WorldCache]?: World; + [StubbedWorldCache]?: World; [WorldCachePromise]?: Promise; - [WorldLifecycleKey]?: WorldLifecycle; + [StubbedWorldCachePromise]?: Promise; } = globalThis; -function getWorkflowConfig() { - return getRuntimeWorkflowConfig() ?? {}; -} - -export function usesConfiguredWorld(): boolean { - return !process.env.WORKFLOW_TARGET_WORLD && !!getWorkflowConfig().world; +function getConfiguredWorldProvider() { + if (process.env.WORKFLOW_TARGET_WORLD) return; + return configuredWorldProvider; } -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)')` @@ -68,7 +59,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 @@ -91,7 +82,10 @@ function resolveModulePath(specifier: string): string { * vars should call createVercelWorld() directly with an explicit config and * use setWorld() to inject the instance. */ -async function createLegacyWorld(): Promise { +export const createWorld = async (): Promise => { + const provider = getConfiguredWorldProvider(); + if (provider) return provider(); + const targetWorld = resolveWorkflowTargetWorld(); if (isVercelWorldTarget(targetWorld)) { @@ -144,199 +138,68 @@ async function createLegacyWorld(): 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 = getWorkflowConfig(); - - if (process.env.WORKFLOW_TARGET_WORLD) { - return { - type: 'legacy', - world: await createLegacyWorld(), - }; - } - - if (config.world) { - const world = await config.world(); - assert(world, 'Configured World provider must return a World.'); - return { - type: 'configured', - world, - }; - } - - return { - type: 'legacy', - world: await createLegacyWorld(), - }; -} - -/** - * Create a new World instance from WORKFLOW_TARGET_WORLD when set, then - * workflow.config.ts, then the environment-aware default. - * - * This function does not call World.start(). Use getWorld() for the managed - * runtime singleton. - */ -export const createWorld = async (): Promise => { - return (await resolveWorld()).world; }; export type WorldHandlers = Pick; /** - * 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. + * Configured providers share the runtime singleton. Environment-selected + * Worlds keep the legacy handler cache so build-time access cannot cache + * incomplete runtime configuration. */ -export const getWorldHandlers = (): Promise => getWorld(); - -export const getWorld = async (): Promise => { - const lifecycle = globalSymbols[WorldLifecycleKey]; - if (lifecycle?.status === 'closing') { - await lifecycle.promise; - } else if (lifecycle?.status === 'cleanup') { - await closeWorld(); +export const getWorldHandlers = async (): Promise => { + if (getConfiguredWorldProvider()) return getWorld(); + + 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; + return { + createQueueHandler: _world.createQueueHandler, + specVersion: _world.specVersion, + }; +}; +export const getWorld = async (): Promise => { if (globalSymbols[WorldCache]) { return globalSymbols[WorldCache]; } - - let pendingWorld = globalSymbols[WorldCachePromise]; - if (!pendingWorld) { - const managed = - !process.env.WORKFLOW_TARGET_WORLD && !!getWorkflowConfig().world; - globalSymbols[WorldLifecycleKey] = managed - ? { status: 'managed' } - : undefined; - pendingWorld = resolveWorld().then(async (resolved) => { - switch (resolved.type) { - case 'configured': - try { - await resolved.world.start?.(); - } catch (startError) { - try { - await resolved.world.close?.(); - } catch (closeError) { - globalSymbols[WorldLifecycleKey] = { - status: 'cleanup', - world: resolved.world, - }; - throw new AggregateError( - [startError, closeError], - 'World startup and cleanup failed.' - ); - } - throw startError; - } - 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; - globalSymbols[WorldCachePromise] = undefined; - } - return world; - } catch (error) { - if (globalSymbols[WorldCachePromise] === pendingWorld) { - globalSymbols[WorldCachePromise] = undefined; - if (globalSymbols[WorldLifecycleKey]?.status === 'managed') { - globalSymbols[WorldLifecycleKey] = undefined; - } - } - throw error; - } + // 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]) { + const configured = !!getConfiguredWorldProvider(); + globalSymbols[WorldCachePromise] = createWorld() + .then(async (world) => { + if (configured) await world.start?.(); + return world; + }) + .catch((err) => { + globalSymbols[WorldCachePromise] = undefined; + throw err; + }); + } + globalSymbols[WorldCache] = await globalSymbols[WorldCachePromise]; + return globalSymbols[WorldCache]; }; -/** Override or clear an unmanaged cached World. */ +/** + * Reset the cached world instance. This should be called when environment + * variables change and you need to reinitialize the world with new config. + */ export const setWorld = (world: World | undefined): void => { - const lifecycle = globalSymbols[WorldLifecycleKey]; - assert( - lifecycle?.status !== 'closing', - 'Cannot replace a World while it is closing.' - ); - assert( - lifecycle?.status !== 'managed' && lifecycle?.status !== 'cleanup', - 'Call await closeWorld() before replacing a managed World.' - ); - globalSymbols[WorldCache] = world; + globalSymbols[StubbedWorldCache] = world; globalSymbols[WorldCachePromise] = undefined; - globalSymbols[WorldLifecycleKey] = undefined; -}; - -function restoreLifecycleAfterCloseFailure( - cleanupWorld: World | undefined, - managed: boolean -): void { - if (cleanupWorld) { - globalSymbols[WorldLifecycleKey] = { - status: 'cleanup', - world: cleanupWorld, - }; - return; - } - - globalSymbols[WorldLifecycleKey] = - managed && (globalSymbols[WorldCache] || globalSymbols[WorldCachePromise]) - ? { status: 'managed' } - : undefined; -} - -/** - * Close the cached World without creating one just for cleanup. - */ -export const closeWorld = async (): Promise => { - const lifecycle = globalSymbols[WorldLifecycleKey]; - if (lifecycle?.status === 'closing') return lifecycle.promise; - - const cachedWorld = globalSymbols[WorldCache]; - const cleanupWorld = - lifecycle?.status === 'cleanup' ? lifecycle.world : undefined; - const worldPromise = cleanupWorld - ? Promise.resolve(cleanupWorld) - : cachedWorld - ? Promise.resolve(cachedWorld) - : globalSymbols[WorldCachePromise]; - if (!worldPromise) return; - - const managed = - lifecycle?.status === 'managed' || lifecycle?.status === 'cleanup'; - const closePromise = (async () => { - try { - const world = await worldPromise; - await world.close?.(); - globalSymbols[WorldCache] = undefined; - globalSymbols[WorldCachePromise] = undefined; - globalSymbols[WorldLifecycleKey] = undefined; - } catch (error) { - if (globalSymbols[WorldLifecycleKey]?.status === 'closing') { - restoreLifecycleAfterCloseFailure(cleanupWorld, managed); - } - throw error; - } - })(); - globalSymbols[WorldLifecycleKey] = { - status: 'closing', - managed, - promise: closePromise, - }; - return closePromise; + globalSymbols[StubbedWorldCachePromise] = undefined; }; // Register getWorld on globalThis so getWorldLazy can call it directly when diff --git a/packages/next/package.json b/packages/next/package.json index 74199d4d0c..3c5bbe29f3 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -36,7 +36,6 @@ "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 31501731e3..8a5adc5720 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -1,5 +1,5 @@ import { constants } from 'node:fs'; -import { access, copyFile, mkdir, rm, stat, writeFile } from 'node:fs/promises'; +import { access, copyFile, mkdir, stat, writeFile } from 'node:fs/promises'; import { extname, join, relative, resolve } from 'node:path'; import type { NextConfig as BuilderNextConfig, @@ -20,7 +20,7 @@ export async function getNextBuilderEager() { const { BaseBuilder: BaseBuilderClass, - createWorkflowQueueTrigger, + WORKFLOW_QUEUE_TRIGGER, // 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")' @@ -70,12 +70,11 @@ export async function getNextBuilderEager() { }); // Expose manifest as a static file when WORKFLOW_PUBLIC_MANIFEST=1. - const publicManifestDir = join( - this.config.workingDir, - 'public/.well-known/workflow/v1' - ); - await rm(join(publicManifestDir, 'manifest.json'), { force: true }); if (this.shouldExposePublicManifest && manifestJson) { + const publicManifestDir = join( + this.config.workingDir, + 'public/.well-known/workflow/v1' + ); await mkdir(publicManifestDir, { recursive: true }); if (process.env.VERCEL_DEPLOYMENT_ID === undefined) { await writeFile(join(publicManifestDir, '.gitignore'), '*'); @@ -396,7 +395,7 @@ export async function getNextBuilderEager() { protected async getInputFiles(): Promise { const inputFiles = await super.getInputFiles(); - if (this.config.workflowConfig?.config.build?.dirs) return inputFiles; + if (this.config.dirs.join() !== '.') return inputFiles; return inputFiles.filter((file) => { const entry = relative(this.config.workingDir, file).replaceAll( @@ -438,9 +437,7 @@ export async function getNextBuilderEager() { version: '0', workflows: { maxDuration: 'max', - experimentalTriggers: [ - createWorkflowQueueTrigger({ namespace: this.queueNamespace }), - ], + experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER], }, }; diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index f6f5e46097..a1bc4b459b 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 } from 'node:path'; +import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { @@ -61,9 +61,9 @@ 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_SOURCEMAP: process.env.WORKFLOW_SOURCEMAP, WORKFLOW_TARGET_WORLD: process.env.WORKFLOW_TARGET_WORLD, }; @@ -79,9 +79,9 @@ 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_SOURCEMAP; delete process.env.WORKFLOW_TARGET_WORLD; }); @@ -139,64 +139,12 @@ describe('withWorkflow builder config', () => { ); }); - 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 () => {};' - ); - - try { - const config = withWorkflow({}); - await config('phase-production-server', { defaultConfig: {} }); - - 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('resolves the runtime binding from Next.js detected root', async () => { - const projectDir = mkdtempSync(join(realTmpDir, 'workflow-next-root-')); - process.chdir(projectDir); - writeFile( - join(projectDir, 'workflow.config.ts'), - `export default { world: './workflow.world.ts' };` - ); - writeFile( - join(projectDir, 'workflow.world.ts'), - 'export default () => {};' - ); + it('does not prewarm the SWC plugin cache for the production server', async () => { + const config = withWorkflow({}); - try { - const config = withWorkflow({}); - const resolvedConfig = await config('phase-production-build', { - defaultConfig: {}, - }); + await config('phase-production-server', { defaultConfig: {} }); - expect( - (resolvedConfig.turbopack?.resolveAlias as Record)[ - '@workflow/config/runtime-binding' - ] - ).toBe('./node_modules/.cache/workflow/runtime-config.mjs'); - } finally { - process.chdir(originalCwd); - rmSync(projectDir, { recursive: true, force: true }); - } + expect(prewarmWorkflowSwcPluginCacheMock).not.toHaveBeenCalled(); }); it('configures diagnostics inside the default Next.js dist dir', async () => { @@ -273,96 +221,39 @@ describe('withWorkflow builder config', () => { expect(webpackConfig?.externals).toEqual([{ react: 'commonjs react' }]); }); - it('applies workflow.config.ts to the Next builder and runtime binding', async () => { + it('loads shared build config without running the World provider', async () => { const projectDir = mkdtempSync(join(realTmpDir, 'workflow-next-config-')); process.chdir(projectDir); writeFile( join(projectDir, 'workflow.world.ts'), - `export default () => { - throw new Error('World provider must not run during builds'); -};` + `throw new Error('World provider ran during build');` ); writeFile( join(projectDir, 'workflow.config.ts'), `export default { - world: './workflow.world.ts', - build: { - dirs: ['jobs'], - projectRoot: '../repo-root', - externalPackages: ['configured-external'], - sourcemap: false, - manifest: { public: true, output: 'custom-manifest.json' } - }, - queue: { namespace: 'myapp' } -};` + world: './workflow.world.ts', + build: { dirs: ['jobs'], projectRoot: '..', sourcemap: false } + };` ); - process.env.PORT = '9876'; - process.env.WORKFLOW_LOCAL_BASE_URL = 'http://localhost:9876'; - let observedBaseUrl: string | undefined; - try { - const turbopackRoot = dirname(projectDir); - const config = withWorkflow( - async () => { - observedBaseUrl = process.env.WORKFLOW_LOCAL_BASE_URL; - return { - outputFileTracingRoot: '/explicit-root', - turbopack: { root: turbopackRoot }, - }; - }, - { workflows: { local: { port: 4000 } } } - ); - const resolvedConfig = await config('phase-production-build', { - defaultConfig: {}, - }); + const nextConfig = await withWorkflow({})('phase-production-build', { + defaultConfig: {}, + }); + const worldModule = join(projectDir, 'workflow.world.ts'); - expect(process.env.PORT).toBe('4000'); - expect(observedBaseUrl).toBe('http://localhost:4000'); - expect(process.env.WORKFLOW_TARGET_WORLD).toBeUndefined(); - expect(process.env.WORKFLOW_LOCAL_DATA_DIR).toBe('.next/workflow-data'); - expect(builderConfigs[0]).toMatchObject({ - dirs: ['jobs'], - projectRoot: '/explicit-root', - workflowConfig: { - path: join(projectDir, 'workflow.config.ts'), - runtimePath: join( - projectDir, - 'node_modules/.cache/workflow/runtime-config.mjs' - ), - config: { - world: './workflow.world.ts', - build: { - sourcemap: false, - manifest: { - public: true, - output: 'custom-manifest.json', - }, - }, - queue: { namespace: 'myapp' }, - }, - }, - }); - expect(builderConfigs[0]?.externalPackages).toContain( - 'configured-external' - ); - const runtimeConfigRequest = relative( - turbopackRoot, - join(projectDir, 'node_modules/.cache/workflow/runtime-config.mjs') - ).replaceAll('\\', '/'); - expect( - (resolvedConfig.turbopack?.resolveAlias as Record)[ - '@workflow/config/runtime-binding' - ] - ).toBe( - runtimeConfigRequest.startsWith('.') - ? runtimeConfigRequest - : `./${runtimeConfigRequest}` - ); - } finally { - process.chdir(originalCwd); - rmSync(projectDir, { recursive: true, force: true }); - } + expect(builderConfigs[0]).toMatchObject({ + dirs: ['jobs'], + projectRoot: '..', + sourcemap: false, + worldModule, + }); + expect( + (nextConfig.turbopack?.resolveAlias as Record)[ + '@workflow/world/provider' + ] + ).toBe('./workflow.world.ts'); }); + 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 c1149d546c..e20992572c 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -1,7 +1,7 @@ import { copyFileSync, mkdirSync, statSync } from 'node:fs'; import { copyFile, mkdir, readFile } from 'node:fs/promises'; -import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; -import type { SourcemapMode, WorkflowConfigLoader } from '@workflow/config'; +import { dirname, isAbsolute, join, relative } from 'node:path'; +import type { SourcemapMode, WorkflowConfigLoader } from '@workflow/builders'; import type { NextConfig } from 'next'; import semver from 'semver'; import { getNextBuilder } from './builder.js'; @@ -340,15 +340,15 @@ 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}`; - } + 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}`; } + } + if (phase === 'phase-production-server') { return typeof nextConfigOrFn === 'function' ? await nextConfigOrFn(phase, ctx) : nextConfigOrFn; @@ -366,23 +366,13 @@ export function withWorkflow( } const loaderPath = require.resolve('./loader'); - const { loadWorkflowConfig } = require('@workflow/config/load') as { - loadWorkflowConfig: WorkflowConfigLoader; - }; - const loadedWorkflowConfig = await loadWorkflowConfig({ + const { loadWorkflowConfig } = + require('@workflow/builders/workflow-config') as { + loadWorkflowConfig: WorkflowConfigLoader; + }; + const { config: workflowConfig, worldModule } = await loadWorkflowConfig({ cwd: process.cwd(), - integration: 'next', }); - const workflowConfig = loadedWorkflowConfig.config; - const runtimeConfigPath = loadedWorkflowConfig.runtimePath; - - 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}`; - } - } let nextConfig = typeof nextConfigOrFn === 'function' @@ -394,7 +384,6 @@ export function withWorkflow( nextConfig.serverExternalPackages = [ ...new Set([ ...(nextConfig.serverExternalPackages || []), - ...(workflowConfig.build?.externalPackages || []), // Keep the Vercel world and its native-prone dependencies external so // local builds do not try to parse @vercel/queue's keyring dependency // tree. @@ -459,32 +448,28 @@ export function withWorkflow( nextConfig.turbopack.rules = {}; } const nextVersion = resolveNextVersion(process.cwd()); - if (runtimeConfigPath) { - const existingResolveAlias = isPlainObject( - nextConfig.turbopack.resolveAlias - ) - ? nextConfig.turbopack.resolveAlias - : {}; - const turbopackRoot = resolve( + if (worldModule) { + const turbopackRoot = nextConfig.turbopack.root ?? - nextConfig.outputFileTracingRoot ?? - (semver.gte(nextVersion, '16.0.0') - ? ( - require('next/dist/lib/find-root') as { - findRootDirAndLockFiles(cwd: string): { rootDir: string }; - } - ).findRootDirAndLockFiles(process.cwd()).rootDir - : process.cwd()) + nextConfig.outputFileTracingRoot ?? + (semver.gte(nextVersion, '16.0.0') + ? ( + require('next/dist/lib/find-root') as { + findRootDirAndLockFiles(cwd: string): { rootDir: string }; + } + ).findRootDirAndLockFiles(process.cwd()).rootDir + : process.cwd()); + const worldRequest = relative(turbopackRoot, worldModule).replaceAll( + '\\', + '/' ); - const runtimeConfigRequest = relative( - turbopackRoot, - runtimeConfigPath - ).replaceAll('\\', '/'); nextConfig.turbopack.resolveAlias = { - ...existingResolveAlias, - '@workflow/config/runtime-binding': runtimeConfigRequest.startsWith('.') - ? runtimeConfigRequest - : `./${runtimeConfigRequest}`, + ...(isPlainObject(nextConfig.turbopack.resolveAlias) + ? nextConfig.turbopack.resolveAlias + : {}), + '@workflow/world/provider': worldRequest.startsWith('.') + ? worldRequest + : `./${worldRequest}`, }; } const existingRules = nextConfig.turbopack.rules as any; @@ -519,14 +504,20 @@ export function withWorkflow( 'jsx', 'js', ], - projectRoot: nextConfig.outputFileTracingRoot, + projectRoot: + nextConfig.outputFileTracingRoot ?? + workflowConfig.build?.projectRoot, moduleSpecifierRoot: process.cwd(), workingDir: process.cwd(), distDir, diagnosticsDir: `${distDir}/diagnostics`, buildTarget: 'next', - sourcemap: workflows?.sourcemap, - workflowConfig: loadedWorkflowConfig, + sourcemap: + workflows?.sourcemap ?? + (process.env.WORKFLOW_SOURCEMAP + ? undefined + : workflowConfig.build?.sourcemap), + worldModule, 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 @@ -593,22 +584,22 @@ export function withWorkflow( test: /.*\.(mjs|cjs|cts|ts|tsx|js|jsx)$/, loader: loaderPath, }); - if (runtimeConfigPath) { + if (worldModule) { webpackConfig.resolve ||= {}; const aliases = webpackConfig.resolve.alias; if (Array.isArray(aliases)) { webpackConfig.resolve.alias = [ ...aliases, { - name: '@workflow/config/runtime-binding', - alias: runtimeConfigPath, + name: '@workflow/world/provider', + alias: worldModule, onlyModule: true, }, ]; } else { webpackConfig.resolve.alias = { ...(aliases || {}), - '@workflow/config/runtime-binding': runtimeConfigPath, + '@workflow/world/provider': worldModule, }; } } diff --git a/packages/nitro/package.json b/packages/nitro/package.json index 2eb974c9ac..e1abf651d1 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -28,7 +28,6 @@ "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 951b3b40a1..17bab4bdfc 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -1,45 +1,36 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { - BaseBuilder, - createBaseBuilderConfig, - VercelBuildOutputAPIBuilder, -} from '@workflow/builders'; -import type { LoadedWorkflowConfig } from '@workflow/config/load'; +import { BaseBuilder, VercelBuildOutputAPIBuilder } from '@workflow/builders'; import type { Nitro } from 'nitro/types'; import { join } from 'pathe'; type NitroV2ExternalsOptions = { externals?: { external?: unknown[] } }; -function createNitroBuilderConfig( - nitro: Nitro, - loadedConfig: LoadedWorkflowConfig -) { - const build = loadedConfig.config.build; - // Nitro v3 dropped `externals.external`, so this v2-shaped read is empty. - const nitroExternals = - (nitro.options as NitroV2ExternalsOptions).externals?.external ?? []; - const externalPackages = [ - ...new Set([ - ...(build?.externalPackages ?? []), - ...nitroExternals.filter( - (entry): entry is string => typeof entry === 'string' - ), - ]), - ]; +type NitroBuilderConfig = { + projectRoot?: string; + worldModule?: string; +}; - return createBaseBuilderConfig({ +function createNitroBuilderConfig(nitro: Nitro, config: NitroBuilderConfig) { + const externals = ( + nitro.options as NitroV2ExternalsOptions + ).externals?.external?.filter( + (entry): entry is string => typeof entry === 'string' + ); + const externalPackages = externals?.length ? externals : undefined; + + return { + ...config, workingDir: nitro.options.rootDir, - dirs: nitro.options.workflow?.dirs ?? build?.dirs ?? ['.'], + dirs: nitro.options.workflow?.dirs ?? ['.'], sourcemap: nitro.options.workflow?.sourcemap, - externalPackages: externalPackages.length ? externalPackages : undefined, - workflowConfig: loadedConfig, - }); + externalPackages, + }; } export class VercelBuilder extends VercelBuildOutputAPIBuilder { - constructor(nitro: Nitro, loadedConfig: LoadedWorkflowConfig) { + constructor(nitro: Nitro, config: NitroBuilderConfig = {}) { super({ - ...createNitroBuilderConfig(nitro, loadedConfig), + ...createNitroBuilderConfig(nitro, config), runtime: nitro.options.workflow?.runtime, buildTarget: 'vercel-build-output-api', }); @@ -59,10 +50,10 @@ export class VercelBuilder extends VercelBuildOutputAPIBuilder { export class LocalBuilder extends BaseBuilder { #outDir: string; - constructor(nitro: Nitro, loadedConfig: LoadedWorkflowConfig) { + constructor(nitro: Nitro, config: NitroBuilderConfig = {}) { const outDir = join(nitro.options.buildDir, 'workflow'); super({ - ...createNitroBuilderConfig(nitro, loadedConfig), + ...createNitroBuilderConfig(nitro, config), watch: nitro.options.dev, buildTarget: 'next', // Placeholder, not actually used }); diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts index 901e57e6c4..ffb638fbad 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -1,41 +1,14 @@ -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 { afterEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { LocalBuilder, VercelBuilder } from './builders.js'; import nitroModule from './index.js'; -const projects: string[] = []; -const originalEnv = { - WORKFLOW_QUEUE_NAMESPACE: process.env.WORKFLOW_QUEUE_NAMESPACE, - WORKFLOW_PUBLIC_MANIFEST: process.env.WORKFLOW_PUBLIC_MANIFEST, -}; - -function createProject(config: string): string { - const project = mkdtempSync(join(tmpdir(), 'workflow-nitro-config-')); - projects.push(project); - writeFileSync(join(project, 'workflow.config.ts'), config); - return project; -} - -afterEach(() => { - for (const project of projects.splice(0)) { - rmSync(project, { recursive: true, force: true }); - } - for (const [key, value] of Object.entries(originalEnv)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } -}); - type StubOptions = { routing: boolean; majorVersion?: number; dev?: boolean; preset?: string; workflow?: { runtime?: string }; - rootDir?: string; externals?: { external?: Array boolean)>; }; @@ -48,7 +21,6 @@ function createNitroStub({ dev = false, preset = 'node-server', workflow = {}, - rootDir = process.cwd(), externals, vercel, }: StubOptions) { @@ -61,9 +33,8 @@ function createNitroStub({ dev, externals: externals ?? {}, handlers: [], - plugins: [], preset, - rootDir, + rootDir: '/tmp/project', typescript: {}, vercel: vercel ?? {}, virtual: {}, @@ -137,129 +108,6 @@ 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 configured runtime values as a Nitro plugin', async () => { - const project = createProject( - `export default { queue: { namespace: 'app' } };` - ); - const nitro = createNitroStub({ - routing: false, - dev: true, - rootDir: project, - }); - - await nitroModule.setup(nitro); - - expect(nitro.options.plugins[0]).toBe('#workflow/runtime-config'); - expect(nitro.options.virtual['#workflow/runtime-config']).toContain( - 'import "@workflow/config/runtime-binding";' - ); - }); - - it('keeps configured World providers in lazy chunks', async () => { - const project = createProject( - `export default { world: './workflow.world.ts' };` - ); - writeFileSync( - join(project, 'workflow.world.ts'), - `throw new Error('must stay lazy');` - ); - const nitro = createNitroStub({ routing: true, rootDir: project }); - - await nitroModule.setup(nitro); - - expect(nitro.options.inlineDynamicImports).toBe(false); - }); -}); - -describe('@workflow/nitro workflow.config.ts', () => { - it('applies typed Nitro settings and a namespaced queue trigger', async () => { - const project = createProject( - `export default { - build: { - dirs: ['server/jobs'], - sourcemap: false, - manifest: { public: true } - }, - queue: { namespace: 'myapp' }, - integration: { - type: 'nitro', - typescriptPlugin: true, - runtime: 'nodejs24.x' - } -};` - ); - const nitro = createNitroStub({ - routing: true, - preset: 'vercel', - rootDir: project, - }); - - await nitroModule.setup(nitro); - - expect(nitro.options.workflow).toMatchObject({ - dirs: ['server/jobs'], - typescriptPlugin: true, - runtime: 'nodejs24.x', - }); - expect( - nitro.options.typescript.tsConfig.compilerOptions.plugins - ).toContainEqual({ name: 'workflow' }); - expect( - nitro.options.vercel.functionRules['/.well-known/workflow/v1/flow'] - .experimentalTriggers - ).toEqual([ - expect.objectContaining({ - type: 'queue/v2beta', - topic: '__myapp_wkf_workflow_*', - }), - ]); - expect( - nitro.options.handlers.some( - (handler: { route: string }) => - handler.route === '/.well-known/workflow/v1/manifest.json' - ) - ).toBe(true); - expect(nitro.options.plugins).toContain('#workflow/runtime-config'); - }); - - it('prefers environment variables over workflow.config.ts', async () => { - const project = createProject( - `export default { - build: { manifest: { public: true } }, - queue: { namespace: 'configured' } -};` - ); - process.env.WORKFLOW_QUEUE_NAMESPACE = 'environment'; - process.env.WORKFLOW_PUBLIC_MANIFEST = '0'; - const nitro = createNitroStub({ - routing: true, - preset: 'vercel', - rootDir: project, - }); - - await nitroModule.setup(nitro); - - expect( - nitro.options.vercel.functionRules['/.well-known/workflow/v1/flow'] - .experimentalTriggers[0].topic - ).toBe('__environment_wkf_workflow_*'); - expect( - nitro.options.handlers.some( - (handler: { route: string }) => - handler.route === '/.well-known/workflow/v1/manifest.json' - ) - ).toBe(false); - }); }); describe('@workflow/nitro Vercel functionRules', () => { @@ -398,14 +246,10 @@ describe('@workflow/nitro Vercel functionRules', () => { // routes, so we must NOT touch functionRules — and we must register a // `compiled` hook that runs the VercelBuilder. const compiledHooks: Array<() => void> = []; - const project = createProject( - `export default { queue: { namespace: 'legacy' } };` - ); const nitro = createNitroStub({ routing: false, majorVersion: 2, preset: 'vercel', - rootDir: project, }); nitro.hooks.hook = (name: string, fn: () => void) => { if (name === 'compiled') compiledHooks.push(fn); @@ -415,7 +259,6 @@ describe('@workflow/nitro Vercel functionRules', () => { expect(nitro.options.vercel?.functionRules ?? {}).toEqual({}); expect(compiledHooks.length).toBe(1); - expect(nitro.options.plugins).toContain('#workflow/runtime-config'); }); }); @@ -466,12 +309,6 @@ describe('@workflow/nitro isNitroV2 detection', () => { }); describe('@workflow/nitro externals forwarding', () => { - const loadedConfig = { - path: undefined, - runtimePath: undefined, - config: {}, - } as const; - for (const [label, Builder] of [ ['VercelBuilder', VercelBuilder], ['LocalBuilder', LocalBuilder], @@ -479,7 +316,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, loadedConfig) as any; + const builder = new Builder(nitro) as any; expect(builder.config.externalPackages).toBeUndefined(); }); @@ -488,7 +325,7 @@ describe('@workflow/nitro externals forwarding', () => { routing: true, externals: { external: ['fsevents', 'pg'] }, }); - const builder = new Builder(nitro, loadedConfig) as any; + const builder = new Builder(nitro) as any; expect(builder.config.externalPackages).toEqual(['fsevents', 'pg']); }); @@ -499,7 +336,7 @@ describe('@workflow/nitro externals forwarding', () => { external: [/pkg/, () => true, 'fsevents'], }, }); - const builder = new Builder(nitro, loadedConfig) as any; + const builder = new Builder(nitro) as any; expect(builder.config.externalPackages).toEqual(['fsevents']); }); @@ -508,7 +345,7 @@ describe('@workflow/nitro externals forwarding', () => { routing: true, externals: { external: [/pkg/, () => true] }, }); - const builder = new Builder(nitro, loadedConfig) as any; + const builder = new Builder(nitro) as any; expect(builder.config.externalPackages).toBeUndefined(); }); }); diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index 5c6e5d14e0..4d64733789 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -1,8 +1,8 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { createWorkflowQueueTrigger } from '@workflow/builders'; -import { loadWorkflowConfig } from '@workflow/config/load'; +import { WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders'; +import { loadWorkflowConfig } from '@workflow/builders/workflow-config'; import { workflowTransformPlugin } from '@workflow/rollup'; import type { Nitro, RollupConfig } from 'nitro/types'; import { join } from 'pathe'; @@ -11,8 +11,6 @@ import type { ModuleOptions } from './types'; export type { ModuleOptions }; -const RUNTIME_CONFIG_PLUGIN_ID = '#workflow/runtime-config'; - /** * Detect whether the Nitro instance is v2. * Newer Nitro releases (both v2 and v3) expose `nitro.meta.majorVersion`. @@ -29,44 +27,28 @@ function isNitroV2(nitro: Nitro): boolean { return !nitro.routing; } -export const nitroModule = { +const nitroModule = { name: 'workflow/nitro', async setup(nitro: Nitro): Promise { - const loadedWorkflowConfig = await loadWorkflowConfig({ + const { config: workflowConfig, worldModule } = await loadWorkflowConfig({ cwd: nitro.options.rootDir, - integration: 'nitro', }); - const workflowConfig = loadedWorkflowConfig.config; - const runtimeConfigPath = loadedWorkflowConfig.runtimePath; - if (workflowConfig.world) { - nitro.options.inlineDynamicImports = false; - } - 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 ?? + (process.env.WORKFLOW_SOURCEMAP + ? undefined + : workflowConfig.build?.sourcemap), }; - const publicManifest = - process.env.WORKFLOW_PUBLIC_MANIFEST === undefined - ? (workflowConfig.build?.manifest?.public ?? false) - : process.env.WORKFLOW_PUBLIC_MANIFEST === '1'; - const workflowQueueTrigger = createWorkflowQueueTrigger({ - namespace: - process.env.WORKFLOW_QUEUE_NAMESPACE ?? workflowConfig.queue?.namespace, - }); - if (runtimeConfigPath) { - nitro.options.virtual[RUNTIME_CONFIG_PLUGIN_ID] = ` - import "@workflow/config/runtime-binding"; - export default () => {}; - `; - nitro.options.plugins.unshift(RUNTIME_CONFIG_PLUGIN_ID); + const publicManifest = process.env.WORKFLOW_PUBLIC_MANIFEST === '1'; + const builderConfig = { + projectRoot: workflowConfig.build?.projectRoot, + worldModule, + }; + if (worldModule) { + nitro.options.alias['@workflow/world/provider'] = worldModule; } const isVercelDeploy = !nitro.options.dev && nitro.options.preset === 'vercel'; @@ -77,21 +59,7 @@ export const nitroModule = { // 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) => { - const plugins: unknown[] = []; - if (runtimeConfigPath) { - plugins.push({ - name: 'workflow:runtime-config', - resolveId: { - order: 'pre', - handler(source: string) { - return source === '@workflow/config/runtime-binding' - ? { id: runtimeConfigPath, external: false } - : null; - }, - }, - }); - } - plugins.push( + (config.plugins as Array).unshift( workflowTransformPlugin({ // Exclude pre-built workflow bundles from re-transformation // These are already processed and re-processing causes issues like @@ -99,7 +67,6 @@ export const nitroModule = { exclude: [workflowBuildDir], }) ); - (config.plugins as Array).unshift(...plugins); }); // NOTE: Temporary workaround for debug unenv mock @@ -217,7 +184,7 @@ export const nitroModule = { if (useLegacyVercelBuild) { nitro.hooks.hook('compiled', async () => { - await new VercelBuilder(nitro, loadedWorkflowConfig).build(); + await new VercelBuilder(nitro, builderConfig).build(); }); } @@ -228,7 +195,7 @@ export const nitroModule = { // vercel preset. This lets workflow handlers use nitro features // (storage, database, runtime config, virtual imports, etc.). if (!useLegacyVercelBuild) { - const localBuilder = new LocalBuilder(nitro, loadedWorkflowConfig); + const localBuilder = new LocalBuilder(nitro, builderConfig); let isInitialBuild = true; nitro.hooks.hook('build:before', async () => { @@ -307,7 +274,7 @@ export const nitroModule = { // V2 combined: a single trigger covers both `__wkf_workflow_*` // (workflow orchestration) and `__wkf_step_*` (step execution), // since the same handler dispatches both. - experimentalTriggers: [workflowQueueTrigger], + experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER], }; if (runtime) { diff --git a/packages/nitro/src/types.ts b/packages/nitro/src/types.ts index d01e19cbda..9c1af34ac0 100644 --- a/packages/nitro/src/types.ts +++ b/packages/nitro/src/types.ts @@ -1,4 +1,4 @@ -import type { SourcemapMode } from '@workflow/config'; +import type { SourcemapMode } from '@workflow/builders/workflow-config'; export interface ModuleOptions { /** @internal */ diff --git a/packages/nitro/src/vite.ts b/packages/nitro/src/vite.ts index d4b954434f..330bc31080 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 { nitroModule } from './index.js'; +import nitroModule from './index.js'; export function workflow(options?: ModuleOptions): Plugin[] { let builder: LocalBuilder | undefined; diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index fb0b70ccfa..134e92fda7 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -27,7 +27,6 @@ "dependencies": { "@swc/core": "catalog:", "@workflow/builders": "workspace:*", - "@workflow/config": "workspace:*", "@workflow/rollup": "workspace:*", "@workflow/swc-plugin": "workspace:*", "@workflow/vite": "workspace:*", diff --git a/packages/sveltekit/src/builder.ts b/packages/sveltekit/src/builder.ts index c45b2f72bc..068a881781 100644 --- a/packages/sveltekit/src/builder.ts +++ b/packages/sveltekit/src/builder.ts @@ -25,17 +25,20 @@ const SVELTEKIT_VIRTUAL_MODULES = [ export class SvelteKitBuilder extends BaseBuilder { constructor(config: Partial = {}) { const workingDir = config.workingDir ?? process.cwd(); - const build = config.workflowConfig?.config.build; super({ ...config, - dirs: config.dirs ?? - build?.dirs ?? ['workflows', 'src/workflows', 'routes', 'src/routes'], + dirs: config.dirs ?? [ + 'workflows', + 'src/workflows', + 'routes', + 'src/routes', + ], buildTarget: 'sveltekit' as const, workingDir, externalPackages: [ ...SVELTEKIT_VIRTUAL_MODULES, - ...(config.externalPackages ?? build?.externalPackages ?? []), + ...(config.externalPackages ?? []), ], }); } @@ -104,12 +107,11 @@ export const POST = async ({request}) => { // Expose manifest as a static file when WORKFLOW_PUBLIC_MANIFEST=1. // SvelteKit serves files from static/ at the root URL. - const staticManifestDir = join( - this.config.workingDir, - 'static/.well-known/workflow/v1' - ); - await rm(join(staticManifestDir, 'manifest.json'), { force: true }); if (this.shouldExposePublicManifest && manifestJson) { + const staticManifestDir = join( + this.config.workingDir, + 'static/.well-known/workflow/v1' + ); await mkdir(staticManifestDir, { recursive: true }); if (process.env.VERCEL_DEPLOYMENT_ID === undefined) { await writeFile(join(staticManifestDir, '.gitignore'), '*'); diff --git a/packages/sveltekit/src/index.ts b/packages/sveltekit/src/index.ts index 4cb2c193ca..53ced1044e 100644 --- a/packages/sveltekit/src/index.ts +++ b/packages/sveltekit/src/index.ts @@ -1,8 +1,7 @@ import path from 'node:path'; -import { createWorkflowQueueTrigger } from '@workflow/builders'; +import { WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders'; import fs from 'fs-extra'; -import { loadedWorkflowConfig } from './plugin.js'; import { stripWorkflowQueueTriggers } from './vc-config.js'; process.on('beforeExit', () => { @@ -10,11 +9,6 @@ process.on('beforeExit', () => { if (!process.env.VERCEL_DEPLOYMENT_ID) { return; } - const workflowQueueTrigger = createWorkflowQueueTrigger({ - namespace: - process.env.WORKFLOW_QUEUE_NAMESPACE ?? - loadedWorkflowConfig.config.queue?.namespace, - }); // V2: Only the combined flow handler needs queue triggers. // The separate step route was removed. for (const { file, config } of [ @@ -22,7 +16,7 @@ process.on('beforeExit', () => { file: '.vercel/output/functions/.well-known/workflow/v1/flow.func/.vc-config.json', config: { maxDuration: 'max', - experimentalTriggers: [workflowQueueTrigger], + experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER], }, }, ]) { diff --git a/packages/sveltekit/src/plugin.ts b/packages/sveltekit/src/plugin.ts index 5efeb8d359..7e9f5f8f76 100644 --- a/packages/sveltekit/src/plugin.ts +++ b/packages/sveltekit/src/plugin.ts @@ -1,15 +1,14 @@ import { createBuildQueue } from '@workflow/builders'; -import type { SourcemapMode } from '@workflow/config'; -import { loadWorkflowConfig } from '@workflow/config/load'; +import { + loadWorkflowConfig, + type SourcemapMode, +} from '@workflow/builders/workflow-config'; import { workflowTransformPlugin } from '@workflow/rollup'; import { workflowHotUpdatePlugin } from '@workflow/vite'; import type { Plugin } from 'vite'; import { SvelteKitBuilder } from './builder.js'; -export const loadedWorkflowConfig = await loadWorkflowConfig({ - cwd: process.cwd(), - integration: 'sveltekit', -}); +const loadedWorkflowConfig = await loadWorkflowConfig({ cwd: process.cwd() }); export interface WorkflowPluginOptions { /** @@ -22,9 +21,14 @@ export interface WorkflowPluginOptions { } export function workflowPlugin(options: WorkflowPluginOptions = {}): Plugin[] { + const build = loadedWorkflowConfig.config.build; const builder = new SvelteKitBuilder({ - sourcemap: options.sourcemap, - workflowConfig: loadedWorkflowConfig, + dirs: build?.dirs, + projectRoot: build?.projectRoot, + worldModule: loadedWorkflowConfig.worldModule, + sourcemap: + options.sourcemap ?? + (process.env.WORKFLOW_SOURCEMAP ? undefined : build?.sourcemap), }); const enqueue = createBuildQueue(); const initialBuild = builder.build(); @@ -36,16 +40,16 @@ export function workflowPlugin(options: WorkflowPluginOptions = {}): Plugin[] { enforce: 'pre', async config() { await initialBuild; - if (!loadedWorkflowConfig.runtimePath) return; + if (!loadedWorkflowConfig.worldModule) return; return { + resolve: { + alias: { + '@workflow/world/provider': loadedWorkflowConfig.worldModule, + }, + }, ssr: { noExternal: ['workflow', '@workflow/core'] }, }; }, - resolveId(source) { - if (source === '@workflow/config/runtime-binding') { - return loadedWorkflowConfig.runtimePath; - } - }, }, workflowHotUpdatePlugin({ builder, diff --git a/packages/sveltekit/src/vc-config.test.ts b/packages/sveltekit/src/vc-config.test.ts index 52401db697..ef8df10704 100644 --- a/packages/sveltekit/src/vc-config.test.ts +++ b/packages/sveltekit/src/vc-config.test.ts @@ -33,16 +33,6 @@ describe('stripWorkflowQueueTriggersFromConfig', () => { }); }); - it('removes namespaced workflow queue triggers', () => { - expect( - stripWorkflowQueueTriggersFromConfig({ - experimentalTriggers: [ - { ...WORKFLOW_QUEUE_TRIGGER, topic: '__myapp_wkf_workflow_*' }, - ], - }) - ).toEqual({}); - }); - it('leaves configs without workflow triggers unchanged', () => { const config = { runtime: 'nodejs', diff --git a/packages/sveltekit/src/vc-config.ts b/packages/sveltekit/src/vc-config.ts index 13a2201039..3c9fc50e42 100644 --- a/packages/sveltekit/src/vc-config.ts +++ b/packages/sveltekit/src/vc-config.ts @@ -1,15 +1,15 @@ +import { WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders'; import fs from 'fs-extra'; +const WORKFLOW_QUEUE_TOPICS = new Set([WORKFLOW_QUEUE_TRIGGER.topic]); + function isWorkflowQueueTrigger(trigger: unknown) { if (typeof trigger !== 'object' || trigger === null) { return false; } const topic = (trigger as { topic?: unknown }).topic; - return ( - typeof topic === 'string' && - /^__(?:[a-z][a-z0-9]*_)?wkf_workflow_\*$/.test(topic) - ); + return typeof topic === 'string' && WORKFLOW_QUEUE_TOPICS.has(topic); } export function stripWorkflowQueueTriggersFromConfig< diff --git a/packages/web/app/server/workflow-server-actions.server.test.ts b/packages/web/app/server/workflow-server-actions.server.test.ts deleted file mode 100644 index 5e31dec1d2..0000000000 --- a/packages/web/app/server/workflow-server-actions.server.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { World } from '@workflow/world'; -import { afterEach, expect, it } from 'vitest'; -import { getPublicServerConfig } from './workflow-server-actions.server.js'; - -const runtimeConfig = Symbol.for('@workflow/config/runtime'); -const globals = globalThis as typeof globalThis & { - [key: symbol]: { world?: () => World } | undefined; -}; -const targetWorld = process.env.WORKFLOW_TARGET_WORLD; -const deploymentId = process.env.VERCEL_DEPLOYMENT_ID; - -afterEach(() => { - delete globals[runtimeConfig]; - if (targetWorld === undefined) delete process.env.WORKFLOW_TARGET_WORLD; - else process.env.WORKFLOW_TARGET_WORLD = targetWorld; - if (deploymentId === undefined) delete process.env.VERCEL_DEPLOYMENT_ID; - else process.env.VERCEL_DEPLOYMENT_ID = deploymentId; -}); - -it('identifies a configured World without treating it as local', async () => { - delete process.env.WORKFLOW_TARGET_WORLD; - delete process.env.VERCEL_DEPLOYMENT_ID; - globals[runtimeConfig] = { world: () => ({}) as World }; - - await expect(getPublicServerConfig()).resolves.toMatchObject({ - backendId: 'configured', - backendDisplayName: 'Configured', - }); -}); diff --git a/packages/web/app/server/workflow-server-actions.server.ts b/packages/web/app/server/workflow-server-actions.server.ts index a93c796088..62ec90c71a 100644 --- a/packages/web/app/server/workflow-server-actions.server.ts +++ b/packages/web/app/server/workflow-server-actions.server.ts @@ -8,7 +8,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import * as workflowRunHelpers from '@workflow/core/runtime'; -import { getWorld } from '@workflow/core/runtime'; +import { createWorld } from '@workflow/core/runtime'; import { type HealthCheckEndpoint, type HealthCheckResult, @@ -16,7 +16,7 @@ import { } from '@workflow/core/runtime/helpers'; import { resumeHook as resumeHookRuntime } from '@workflow/core/runtime/resume-hook'; -import { WorkflowRunNotFoundError, WorkflowWorldError } from '@workflow/errors'; +import { WorkflowWorldError, WorkflowRunNotFoundError } from '@workflow/errors'; import { findWorkflowDataDir } from '@workflow/utils/check-data-dir'; import type { Event, @@ -26,7 +26,7 @@ import type { WorkflowRunStatus, World, } from '@workflow/world'; -import { createVercelWorld } from '@workflow/world-vercel'; +import { type APIConfig, createVercelWorld } from '@workflow/world-vercel'; /** * Environment variable map for world configuration. @@ -101,9 +101,6 @@ function getEffectiveBackendId(): string { if (targetWorld) { return targetWorld; } - if (workflowRunHelpers.usesConfiguredWorld()) { - return 'configured'; - } // Match @workflow/core/runtime defaulting: vercel if VERCEL_DEPLOYMENT_ID is set, else local. return process.env.VERCEL_DEPLOYMENT_ID ? 'vercel' : 'local'; } @@ -388,6 +385,15 @@ export type ServerActionResult = | { success: true; data: T } | { success: false; error: ServerActionError }; +/** + * Cache for World instances. + * + * IMPORTANT: + * - We only cache non-vercel worlds. + * - Cache keys are derived from **server-side** WORKFLOW_* env vars only. + */ +const worldCache = new Map(); + /** * Get or create a World instance based on configuration. * @@ -395,6 +401,7 @@ export type ServerActionResult = */ async function getWorldFromEnv(userEnvMap: EnvMap): Promise { const backendId = getEffectiveBackendId(); + if (backendId === 'configured') return workflowRunHelpers.getWorld(); const isVercelWorld = ['vercel', '@workflow/world-vercel'].includes( backendId ); @@ -431,7 +438,21 @@ async function getWorldFromEnv(userEnvMap: EnvMap): Promise { await ensureLocalWorldDataDirEnv(); } - return getWorld(); + // Cache key derived ONLY from WORKFLOW_* env vars. + const workflowEnvEntries = Object.entries(process.env).filter(([key]) => + key.startsWith('WORKFLOW_') + ); + workflowEnvEntries.sort(([a], [b]) => a.localeCompare(b)); + const cacheKey = JSON.stringify(Object.fromEntries(workflowEnvEntries)); + + const cachedWorld = worldCache.get(cacheKey); + if (cachedWorld) { + return cachedWorld; + } + + const world = await createWorld(); + worldCache.set(cacheKey, world); + return world; } /** diff --git a/packages/workflow/package.json b/packages/workflow/package.json index bc78a93c8a..9a3b04c710 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -70,12 +70,13 @@ }, "dependencies": { "@workflow/astro": "workspace:*", + "@workflow/builders": "workspace:*", "@workflow/cli": "workspace:*", - "@workflow/config": "workspace:*", "@workflow/core": "workspace:*", "@workflow/errors": "workspace:*", "@workflow/typescript-plugin": "workspace:*", "@workflow/utils": "workspace:*", + "@workflow/world": "workspace:*", "ms": "2.1.3", "@workflow/next": "workspace:*", "@workflow/nest": "workspace:*", diff --git a/packages/workflow/src/config.ts b/packages/workflow/src/config.ts index e3a851eb14..96f5747a85 100644 --- a/packages/workflow/src/config.ts +++ b/packages/workflow/src/config.ts @@ -1 +1,5 @@ -export * from '@workflow/config'; +export type { + SourcemapMode, + WorkflowConfig, +} from '@workflow/builders/workflow-config'; +export type { WorldProvider } from '@workflow/world'; diff --git a/packages/workflow/src/runtime.ts b/packages/workflow/src/runtime.ts index 5decdd4348..9d8fcb20fa 100644 --- a/packages/workflow/src/runtime.ts +++ b/packages/workflow/src/runtime.ts @@ -1,5 +1,4 @@ export { - closeWorld, createWorld, getWorld, getWorldHandlers, diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts index 39f57d4030..f028dccc3d 100644 --- a/packages/world-local/src/index.ts +++ b/packages/world-local/src/index.ts @@ -2,12 +2,7 @@ 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 { - getQueueTopicPrefix, - reenqueueActiveRuns, - resolveQueueNamespace, - SPEC_VERSION_CURRENT, -} from '@workflow/world'; +import { reenqueueActiveRuns, SPEC_VERSION_CURRENT } from '@workflow/world'; import type { Config } from './config.js'; import { config } from './config.js'; import { @@ -93,11 +88,7 @@ export function createLocalWorld(args?: Partial): LocalWorld { })) as typeof storage.runs.list, } : storage.runs; - await reenqueueActiveRuns( - recoveryRuns, - queue.queue, - getQueueTopicPrefix('workflow', resolveQueueNamespace()) - ); + await reenqueueActiveRuns(recoveryRuns, queue.queue, 'world-local'); }, async close() { await queue.close(); diff --git a/packages/world-local/src/reenqueue.test.ts b/packages/world-local/src/reenqueue.test.ts index d6c1a95fea..2759f1966c 100644 --- a/packages/world-local/src/reenqueue.test.ts +++ b/packages/world-local/src/reenqueue.test.ts @@ -1,8 +1,8 @@ -import { rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { rm } from 'node:fs/promises'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createLocalWorld } from './index.js'; +import { createLocalWorld, type LocalWorld } from './index.js'; import { createRun, updateRun } from './test-helpers.js'; // Mock node:timers/promises so the queue's setTimeout resolves immediately @@ -18,7 +18,6 @@ describe('re-enqueue active runs on start', () => { }); afterEach(async () => { - delete process.env.WORKFLOW_QUEUE_NAMESPACE; await rm(dataDir, { recursive: true, force: true }); }); @@ -122,29 +121,6 @@ describe('re-enqueue active runs on start', () => { await world2.close(); }); - it('uses the active queue namespace', async () => { - const world1 = createLocalWorld({ dataDir }); - await world1.start(); - const run = await createRun(world1, { - deploymentId: 'dpl_1', - workflowName: 'myWorkflow', - input: new Uint8Array([1]), - }); - await world1.close(); - - process.env.WORKFLOW_QUEUE_NAMESPACE = 'myapp'; - const world2 = createLocalWorld({ dataDir }); - const receivedRunIds: string[] = []; - world2.registerHandler('__myapp_wkf_workflow_', async (req) => { - receivedRunIds.push((await req.json()).runId); - return Response.json({ ok: true }); - }); - - await world2.start(); - await vi.waitFor(() => expect(receivedRunIds).toEqual([run.runId])); - await world2.close(); - }); - it('only re-enqueues runs for the matching tag', async () => { const world0 = createLocalWorld({ dataDir, tag: 'vitest-0' }); await world0.start(); diff --git a/packages/world-postgres/HOW_IT_WORKS.md b/packages/world-postgres/HOW_IT_WORKS.md index e6e4069a98..4c13a2a20e 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,25 +33,23 @@ Real-time data streaming via **PostgreSQL LISTEN/NOTIFY**: ## Setup -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. +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. 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 start -the configured provider before request handling: +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): ```ts // instrumentation.ts if (process.env.NEXT_RUNTIME !== "edge") { import("workflow/runtime").then(async ({ getWorld }) => { - await getWorld(); + // start listening to the jobs. + const world = await getWorld(); + await world.start?.(); }); } ``` diff --git a/packages/world-postgres/src/config.ts b/packages/world-postgres/src/config.ts index 943e022311..2c74ff05c9 100644 --- a/packages/world-postgres/src/config.ts +++ b/packages/world-postgres/src/config.ts @@ -8,7 +8,7 @@ export type PostgresWorldConfig = PgConnectionConfig & { jobPrefix?: string; /** * namespace for queue topic prefixes (e.g. 'custom' → '__custom_wkf_workflow_'). - * Used only when no runtime queue namespace is configured. + * defaults to WORKFLOW_QUEUE_NAMESPACE env var if not provided. */ namespace?: string; queueConcurrency?: number; diff --git a/packages/world-postgres/src/index.ts b/packages/world-postgres/src/index.ts index 6ade926518..9ad7565e06 100644 --- a/packages/world-postgres/src/index.ts +++ b/packages/world-postgres/src/index.ts @@ -1,11 +1,5 @@ import type { Storage, World } from '@workflow/world'; -import { - getQueueTopicPrefix, - reenqueueActiveRuns, - resolveQueueNamespace, - SPEC_VERSION_CURRENT, -} from '@workflow/world'; -import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; +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'; @@ -47,7 +41,6 @@ export function createWorld( 50, } ): World & { start(): Promise } { - let usesProviderNamespace = false; const maxPoolSize = config.maxPoolSize ?? getDefaultMaxPoolSize(); const pool = config.pool || @@ -72,16 +65,8 @@ export function createWorld( streamFlushIntervalMs: config.streamFlushIntervalMs, }), async start() { - if (resolveQueueNamespace() === undefined && config.namespace) { - setWorkflowQueueNamespace(config.namespace); - usesProviderNamespace = true; - } await queue.start(); - await reenqueueActiveRuns( - storage.runs, - queue.queue, - getQueueTopicPrefix('workflow', resolveQueueNamespace()) - ); + await reenqueueActiveRuns(storage.runs, queue.queue, 'world-postgres'); }, async close() { await streamer.close(); @@ -89,9 +74,6 @@ export function createWorld( if (pool !== config.pool) { await pool.end(); } - if (usesProviderNamespace) { - setWorkflowQueueNamespace(undefined); - } }, }; } diff --git a/packages/world-postgres/src/queue.test.ts b/packages/world-postgres/src/queue.test.ts index a49916f3b9..83cf885cff 100644 --- a/packages/world-postgres/src/queue.test.ts +++ b/packages/world-postgres/src/queue.test.ts @@ -2,7 +2,6 @@ import { createServer, type Server } from 'node:http'; import { JsonTransport } from '@vercel/queue'; import { getWorkflowPort } from '@workflow/utils/get-port'; import { MessageId, parseQueueName, type QueuePayload } from '@workflow/world'; -import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; import { createLocalWorld } from '@workflow/world-local'; import { makeWorkerUtils, run, type WorkerUtils } from 'graphile-worker'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -15,7 +14,9 @@ const createdQueues: Array> = []; const createdServers: Server[] = []; vi.mock('graphile-worker', () => ({ - Logger: class Logger {}, + Logger: class Logger { + constructor(_: unknown) {} + }, makeWorkerUtils: vi.fn(), run: vi.fn(), })); @@ -51,8 +52,6 @@ describe('postgres queue http execution', () => { beforeEach(() => { vi.clearAllMocks(); - setWorkflowQueueNamespace(undefined); - delete process.env.WORKFLOW_QUEUE_NAMESPACE; vi.mocked(makeWorkerUtils).mockResolvedValue(workerUtilsMock); vi.mocked(getWorkflowPort).mockResolvedValue(undefined); @@ -75,7 +74,6 @@ describe('postgres queue http execution', () => { ); vi.useRealTimers(); delete process.env.WORKFLOW_LOCAL_BASE_URL; - delete process.env.WORKFLOW_QUEUE_NAMESPACE; delete process.env.PORT; }); @@ -258,7 +256,7 @@ describe('postgres queue http execution', () => { } }); - it('uses the runtime namespace before the provider fallback', async () => { + it('serializes namespaced workflow queue execution for the same runId', async () => { let resolveFirstRequestStarted!: () => void; const firstRequestStarted = new Promise((resolve) => { resolveFirstRequestStarted = resolve; @@ -285,7 +283,6 @@ describe('postgres queue http execution', () => { }); vi.stubGlobal('fetch', fetchMock); process.env.WORKFLOW_LOCAL_BASE_URL = 'http://localhost:3000'; - process.env.WORKFLOW_QUEUE_NAMESPACE = 'preview'; const queue = buildQueue( { connectionString: 'postgres://test', namespace: 'custom' }, @@ -299,13 +296,13 @@ describe('postgres queue http execution', () => { runId: 'wrun_01ABC', }; const firstExecution = task( - buildMessageData('__preview_wkf_workflow_test-workflow', payload, { + buildMessageData('__custom_wkf_workflow_test-workflow', payload, { messageId: MessageId.parse('msg_01ABC'), }), {} as any ); const secondExecution = task( - buildMessageData('__preview_wkf_workflow_test-workflow', payload, { + buildMessageData('__custom_wkf_workflow_test-workflow', payload, { messageId: MessageId.parse('msg_01ABD'), }), {} as any diff --git a/packages/world-postgres/src/queue.ts b/packages/world-postgres/src/queue.ts index 25e0be8d90..00db4be87d 100644 --- a/packages/world-postgres/src/queue.ts +++ b/packages/world-postgres/src/queue.ts @@ -488,7 +488,7 @@ export function createQueue( string, (payload: unknown, helpers: unknown) => Promise > = {}; - const namespace = resolveQueueNamespace() ?? config.namespace; + const namespace = resolveQueueNamespace(config.namespace); const workflowPrefix = getQueueTopicPrefix('workflow', namespace); const stepPrefix = getQueueTopicPrefix('step', namespace); taskList[getJobQueueName(workflowPrefix)] = diff --git a/packages/world-postgres/src/reenqueue.test.ts b/packages/world-postgres/src/reenqueue.test.ts index 1fd01062bc..5cd6b49b42 100644 --- a/packages/world-postgres/src/reenqueue.test.ts +++ b/packages/world-postgres/src/reenqueue.test.ts @@ -1,7 +1,11 @@ import { getWorkflowPort } from '@workflow/utils/get-port'; -import { setWorkflowQueueNamespace } from '@workflow/world/queue.js'; import { createLocalWorld } from '@workflow/world-local'; -import { makeWorkerUtils, run, type WorkerUtils } from 'graphile-worker'; +import { + Logger, + makeWorkerUtils, + run, + type WorkerUtils, +} from 'graphile-worker'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createWorld } from './index.js'; import { @@ -10,9 +14,12 @@ import { createRunsStorage, createStepsStorage, } from './storage.js'; +import { createStreamer } from './streamer.js'; vi.mock('graphile-worker', () => ({ - Logger: class Logger {}, + Logger: class Logger { + constructor(_: unknown) {} + }, makeWorkerUtils: vi.fn(), run: vi.fn(), })); @@ -88,8 +95,6 @@ describe('re-enqueue active runs on start', () => { beforeEach(() => { vi.clearAllMocks(); - setWorkflowQueueNamespace(undefined); - delete process.env.WORKFLOW_QUEUE_NAMESPACE; vi.mocked(makeWorkerUtils).mockResolvedValue(workerUtilsMock); vi.mocked(getWorkflowPort).mockResolvedValue(undefined); vi.mocked(run).mockResolvedValue(runnerMock as any); @@ -107,7 +112,6 @@ describe('re-enqueue active runs on start', () => { afterEach(async () => { delete process.env.WORKFLOW_LOCAL_BASE_URL; - delete process.env.WORKFLOW_QUEUE_NAMESPACE; delete process.env.PORT; }); diff --git a/packages/world/package.json b/packages/world/package.json index 4f95f427e2..02ca4ae8cb 100644 --- a/packages/world/package.json +++ b/packages/world/package.json @@ -6,6 +6,10 @@ "main": "dist/index.js", "exports": { ".": "./dist/index.js", + "./provider": { + "types": "./dist/provider.d.ts", + "default": "./dist/provider.js" + }, "./*": "./dist/*" }, "publishConfig": { diff --git a/packages/world/src/provider.ts b/packages/world/src/provider.ts new file mode 100644 index 0000000000..5ef9a8d1cd --- /dev/null +++ b/packages/world/src/provider.ts @@ -0,0 +1,3 @@ +import type { WorldProvider } from './interfaces.js'; + +export default undefined as WorldProvider | undefined; diff --git a/packages/world/src/queue.test.ts b/packages/world/src/queue.test.ts index 6511fcaff8..6031d69633 100644 --- a/packages/world/src/queue.test.ts +++ b/packages/world/src/queue.test.ts @@ -1,41 +1,12 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { 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 df99565ebd..f60ae034a7 100644 --- a/packages/world/src/queue.ts +++ b/packages/world/src/queue.ts @@ -25,37 +25,19 @@ export const ValidQueueName = z ); export type ValidQueueName = z.infer; -export const QueueNamespaceSchema = z +const QueueNamespace = 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; -}; - /** - * Sets the process-local queue namespace resolved from workflow.config.ts. - * Explicit function arguments and WORKFLOW_QUEUE_NAMESPACE take precedence. - */ -export function setWorkflowQueueNamespace(namespace: string | undefined): void { - queueGlobals[WorkflowQueueNamespace] = namespace; -} - -/** - * Resolves the active queue namespace from an explicit argument, the loaded - * WORKFLOW_QUEUE_NAMESPACE env var, or the loaded Workflow config. + * Resolves the active queue namespace from an explicit argument or the + * `WORKFLOW_QUEUE_NAMESPACE` env var. */ export function resolveQueueNamespace(namespace?: string): string | undefined { - return ( - namespace ?? - process.env.WORKFLOW_QUEUE_NAMESPACE ?? - queueGlobals[WorkflowQueueNamespace] - ); + return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE ?? undefined; } /** @@ -69,7 +51,7 @@ export function getQueueTopicPrefix( namespace?: string ): QueuePrefix { if (namespace !== undefined) { - QueueNamespaceSchema.parse(namespace); + QueueNamespace.parse(namespace); return `__${namespace}_wkf_${kind}_` as QueuePrefix; } return `__wkf_${kind}_` as QueuePrefix; diff --git a/packages/world/src/recovery.ts b/packages/world/src/recovery.ts index 43e015ca01..d20cfa33c1 100644 --- a/packages/world/src/recovery.ts +++ b/packages/world/src/recovery.ts @@ -1,5 +1,6 @@ +import type { Queue } from './queue.js'; import type { Storage } from './interfaces.js'; -import type { Queue, QueuePrefix, ValidQueueName } from './queue.js'; +import type { ValidQueueName } from './queue.js'; /** * Re-enqueue all active (pending/running) workflow runs so they resume @@ -8,12 +9,12 @@ import type { Queue, QueuePrefix, ValidQueueName } from './queue.js'; * * @param runs - Storage runs interface for listing active runs * @param enqueue - Queue's enqueue method - * @param workflowPrefix - Active workflow queue prefix + * @param label - Log prefix for identifying the world implementation (e.g. "world-local") */ export async function reenqueueActiveRuns( runs: Storage['runs'], enqueue: Queue['queue'], - workflowPrefix: QueuePrefix + label: string ): Promise { let reenqueued = 0; for (const status of ['pending', 'running'] as const) { @@ -27,13 +28,12 @@ export async function reenqueueActiveRuns( }); for (const run of page.data) { try { - const queueName = - `${workflowPrefix}${run.workflowName}` as ValidQueueName; + const queueName: ValidQueueName = `__wkf_workflow_${run.workflowName}`; await enqueue(queueName, { runId: run.runId }); reenqueued++; } catch (err) { console.warn( - `[workflow] Failed to re-enqueue run ${run.runId}: ${err}` + `[${label}] Failed to re-enqueue run ${run.runId}: ${err}` ); } } @@ -43,7 +43,7 @@ export async function reenqueueActiveRuns( } if (reenqueued > 0) { console.log( - `[workflow] Re-enqueued ${reenqueued} active run(s) on startup` + `[${label}] Re-enqueued ${reenqueued} active run(s) on startup` ); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1a83f0308..7a72355a2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -375,9 +375,6 @@ importers: '@workflow/builders': specifier: workspace:* version: link:../builders - '@workflow/config': - specifier: workspace:* - version: link:../config '@workflow/rollup': specifier: workspace:* version: link:../rollup @@ -409,9 +406,6 @@ importers: '@swc/core': specifier: 'catalog:' version: 1.15.3 - '@workflow/config': - specifier: workspace:* - version: link:../config '@workflow/core': specifier: workspace:* version: link:../core @@ -424,6 +418,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 @@ -439,12 +436,18 @@ importers: find-up: specifier: 7.0.0 version: 7.0.0 + jiti: + specifier: 2.7.0 + version: 2.7.0 json5: specifier: 2.2.3 version: 2.2.3 tinyglobby: specifier: 0.2.17 version: 0.2.17 + zod: + specifier: 'catalog:' + version: 4.3.6 devDependencies: '@types/node': specifier: 'catalog:' @@ -470,9 +473,6 @@ importers: '@workflow/builders': specifier: workspace:* version: link:../builders - '@workflow/config': - specifier: workspace:* - version: link:../config '@workflow/core': specifier: workspace:* version: link:../core @@ -556,31 +556,6 @@ importers: specifier: workspace:* version: link:../tsconfig - packages/config: - dependencies: - '@workflow/world': - specifier: workspace:* - version: link:../world - find-up: - specifier: 7.0.0 - version: 7.0.0 - jiti: - specifier: 2.7.0 - version: 2.7.0 - zod: - specifier: 'catalog:' - version: 4.3.6 - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 22.19.0 - '@workflow/tsconfig': - specifier: workspace:* - version: link:../tsconfig - vitest: - specifier: 'catalog:' - version: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) - packages/core: dependencies: '@aws-sdk/credential-provider-web-identity': @@ -598,9 +573,6 @@ 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 @@ -797,9 +769,6 @@ importers: '@workflow/builders': specifier: workspace:* version: link:../builders - '@workflow/config': - specifier: workspace:* - version: link:../config '@workflow/core': specifier: workspace:* version: link:../core @@ -840,9 +809,6 @@ importers: '@workflow/builders': specifier: workspace:* version: link:../builders - '@workflow/config': - specifier: workspace:* - version: link:../config '@workflow/core': specifier: workspace:* version: link:../core @@ -945,9 +911,6 @@ importers: '@workflow/builders': specifier: workspace:* version: link:../builders - '@workflow/config': - specifier: workspace:* - version: link:../config '@workflow/rollup': specifier: workspace:* version: link:../rollup @@ -1325,12 +1288,12 @@ importers: '@workflow/astro': specifier: workspace:* version: link:../astro + '@workflow/builders': + specifier: workspace:* + version: link:../builders '@workflow/cli': specifier: workspace:* version: link:../cli - '@workflow/config': - specifier: workspace:* - version: link:../config '@workflow/core': specifier: workspace:* version: link:../core @@ -1361,6 +1324,9 @@ importers: '@workflow/utils': specifier: workspace:* version: link:../utils + '@workflow/world': + specifier: workspace:* + version: link:../world ms: specifier: 2.1.3 version: 2.1.3 diff --git a/scripts/stage-workbench-with-tarballs.mjs b/scripts/stage-workbench-with-tarballs.mjs index b8b7e45b35..307a9ef3b5 100644 --- a/scripts/stage-workbench-with-tarballs.mjs +++ b/scripts/stage-workbench-with-tarballs.mjs @@ -1,4 +1,3 @@ -import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; @@ -249,6 +248,32 @@ 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; @@ -326,31 +351,16 @@ function main() { tarballPathByPackageName, catalog ); - - 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') + const overridesApplied = applyTarballOverrides( + stagedPackageJsonPath, + tarballPathByPackageName ); console.log( `Rewrote ${replacedWithTarballs.length} monorepo dependencies to tarballs and ${replacedCatalogEntries.length} catalog dependencies to versions` ); console.log( - `Applied ${tarballPathByPackageName.size} pnpm tarball overrides for transitive monorepo packages` + `Applied ${overridesApplied} pnpm tarball overrides for transitive monorepo packages` ); console.log(`Installing dependencies in ${stagedWorkbenchDir}`);