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