diff --git a/.changeset/framework-shared-config.md b/.changeset/framework-shared-config.md
new file mode 100644
index 0000000000..2cfc706ea3
--- /dev/null
+++ b/.changeset/framework-shared-config.md
@@ -0,0 +1,8 @@
+---
+"@workflow/next": minor
+"@workflow/nitro": minor
+"@workflow/sveltekit": minor
+"@workflow/astro": minor
+---
+
+Add typed shared configuration support to framework integrations.
diff --git a/.changeset/lazy-world-factories.md b/.changeset/lazy-world-factories.md
new file mode 100644
index 0000000000..37ad40477a
--- /dev/null
+++ b/.changeset/lazy-world-factories.md
@@ -0,0 +1,8 @@
+---
+"@workflow/config": minor
+"@workflow/world": minor
+"@workflow/world-local": patch
+"@workflow/world-postgres": patch
+---
+
+Add typed Workflow configuration with module-based lazy World providers.
diff --git a/.changeset/shared-config-runtime.md b/.changeset/shared-config-runtime.md
new file mode 100644
index 0000000000..d763e8fdb4
--- /dev/null
+++ b/.changeset/shared-config-runtime.md
@@ -0,0 +1,9 @@
+---
+"@workflow/builders": minor
+"@workflow/cli": minor
+"@workflow/core": minor
+"@workflow/web": patch
+"workflow": minor
+---
+
+Bundle configured World modules and queue settings across runtime, build, and CLI entry points.
diff --git a/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx b/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx
index acfc585691..98ef46651d 100644
--- a/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx
@@ -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 World, queue,
+and shared build settings. The optional second argument to `withWorkflow`
+overrides environment variables and `workflow.config.ts`.
```typescript title="next.config.ts" lineNumbers
import type { NextConfig } from "next";
@@ -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,19 +120,23 @@ 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.
+The `workflows.local` options only affect local development. When `world` is not
+configured, the runtime automatically uses the Local World locally and the
+Vercel World on Vercel.
## Exporting a Function
-
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/api-reference/workflow-runtime/close-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/close-world.mdx
new file mode 100644
index 0000000000..df34364d8e
--- /dev/null
+++ b/docs/content/docs/v5/api-reference/workflow-runtime/close-world.mdx
@@ -0,0 +1,34 @@
+---
+title: closeWorld
+description: Close and clear the World managed by the workflow runtime.
+type: reference
+summary: Release the active World's resources and reset the runtime cache.
+prerequisites:
+ - /docs/api-reference/workflow-runtime/get-world
+related:
+ - /docs/api-reference/workflow-runtime/set-world
+---
+
+Closes the cached [World](/docs/api-reference/workflow-runtime/world) and clears
+it from the runtime. If a World is still starting, `closeWorld()` waits for
+startup before closing it. It does not create a World when the cache is empty.
+
+```typescript lineNumbers
+import { closeWorld } from "workflow/runtime";
+
+await closeWorld(); // [!code highlight]
+```
+
+The next [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) call
+resolves the active target override, configured provider, or automatic World
+again.
+
+## API Signature
+
+### Parameters
+
+This function does not accept any parameters.
+
+### Returns
+
+Returns a `Promise`.
diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx
index afbe0339fb..a67cbf33bd 100644
--- a/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx
@@ -1,15 +1,18 @@
---
title: createWorld
-description: Create a new World instance from environment configuration.
+description: Resolve a new World instance from the active Workflow configuration.
type: reference
-summary: Use createWorld to instantiate a World from WORKFLOW_TARGET_WORLD environment configuration, bypassing the cached instance.
+summary: Resolve a fresh World without using the managed runtime cache.
prerequisites:
- /docs/api-reference/workflow-runtime/get-world
related:
- /docs/api-reference/workflow-runtime/set-world
---
-Creates a new [World](/docs/api-reference/workflow-runtime/world) instance based on environment configuration. The `WORKFLOW_TARGET_WORLD` environment variable determines which World implementation is instantiated (for example the local development World or the Vercel production World).
+Creates a new [World](/docs/api-reference/workflow-runtime/world) using the
+same selection order as [`getWorld()`](/docs/api-reference/workflow-runtime/get-world):
+`WORKFLOW_TARGET_WORLD`, the provider in `workflow.config.ts`, then the
+automatic Vercel or Local World.
Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), which caches a singleton instance, `createWorld()` constructs a fresh instance on every call. Application code should almost always use `getWorld()` — `createWorld()` is for infrastructure code that manages World lifecycles itself.
@@ -23,7 +26,7 @@ const world = await createWorld(); // [!code highlight]
### Parameters
-This function does not accept any parameters. Configuration is read from environment variables.
+This function does not accept any parameters.
### Returns
diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx
index 4c2bfad0a4..be2b8943cb 100644
--- a/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx
@@ -1,15 +1,16 @@
---
title: getWorldHandlers
-description: Build-time-safe access to the World's queue handlers without binding to runtime environment variables.
+description: Resolve the active World's queue handlers.
type: reference
-summary: Use getWorldHandlers at build time to access queue handler creation without caching an environment-bound World.
+summary: Access queue handler creation through the managed runtime World.
prerequisites:
- /docs/api-reference/workflow-runtime/get-world
---
-Returns a restricted view of the [World](/docs/api-reference/workflow-runtime/world) exposing only the members that are safe to use at build time: `createQueueHandler` and `specVersion`. Framework adapters use it while generating workflow route handlers, before the deployment's runtime environment variables exist.
-
-Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), this function does not cache a fully configured World instance — caching at build time would lock in incomplete environment configuration.
+Returns `createQueueHandler` and `specVersion` from the same managed
+[World](/docs/api-reference/workflow-runtime/world) as
+[`getWorld()`](/docs/api-reference/workflow-runtime/get-world). The first call
+resolves and caches the World, starting configured providers before returning.
```typescript lineNumbers
import { getWorldHandlers } from "workflow/runtime";
@@ -35,7 +36,7 @@ type WorldHandlers = Pick;
```
- This is SDK infrastructure used by framework adapters and the workflow entrypoint. Application code should use [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) instead.
+ This is a low-level infrastructure API. Application code should use [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) instead.
## Related Functions
diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx
index 042e181c11..d6ece115fc 100644
--- a/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx
@@ -26,13 +26,16 @@ These functions are primarily used by framework adapters and custom world setups
- Create a World instance from environment configuration.
+ Resolve a fresh World without using the managed cache.
Override the cached World instance with a custom World.
+
+ Close and clear the managed World.
+
- Build-time-safe access to the World's queue handlers.
+ Resolve queue handlers from the managed World.
Create the HTTP route handler that executes workflow runs.
diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/meta.json b/docs/content/docs/v5/api-reference/workflow-runtime/meta.json
index c8081938a0..97b9205b96 100644
--- a/docs/content/docs/v5/api-reference/workflow-runtime/meta.json
+++ b/docs/content/docs/v5/api-reference/workflow-runtime/meta.json
@@ -5,6 +5,7 @@
"world",
"create-world",
"set-world",
+ "close-world",
"get-world-handlers",
"workflow-entrypoint",
"health-check"
diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx
index 6dcc03d8bd..a3e9420203 100644
--- a/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx
@@ -1,15 +1,20 @@
---
title: setWorld
-description: Override or reset the cached World instance used by the workflow runtime.
+description: Override the cached World instance used by the workflow runtime.
type: reference
-summary: Use setWorld to inject a custom World instance or reset the cache after environment configuration changes.
+summary: Inject a custom World instance into the runtime cache.
prerequisites:
- /docs/api-reference/workflow-runtime/get-world
related:
- /docs/api-reference/workflow-runtime/create-world
+ - /docs/api-reference/workflow-runtime/close-world
---
-Overrides the cached [World](/docs/api-reference/workflow-runtime/world) instance that [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) returns. Use it to inject a World constructed with explicit configuration (rather than environment variables), or pass `undefined` to clear the cache so the next `getWorld()` call reinitializes from the current environment.
+Overrides the cached [World](/docs/api-reference/workflow-runtime/world)
+instance that [`getWorld()`](/docs/api-reference/workflow-runtime/get-world)
+returns. Passing `undefined` can clear a manually injected World. Use
+[`closeWorld()`](/docs/api-reference/workflow-runtime/close-world) to close and
+reset a World started from `workflow.config.ts`.
```typescript lineNumbers
import { setWorld, getWorld } from "workflow/runtime";
@@ -26,19 +31,19 @@ const world = await getWorld(); // resolves customWorld
| Parameter | Type | Description |
|-----------|------|-------------|
-| `world` | `World \| undefined` | The World instance to use, or `undefined` to reset the cache and reinitialize from environment variables on next access |
+| `world` | `World \| undefined` | The World instance to use, or `undefined` to clear a manually injected instance |
### Returns
This function does not return a value.
-## Example: Reset After Environment Changes
+## Resetting the Managed World
```typescript lineNumbers
-import { setWorld, getWorld } from "workflow/runtime";
+import { closeWorld, getWorld } from "workflow/runtime";
process.env.WORKFLOW_TARGET_WORLD = "@workflow/world-local";
-setWorld(undefined); // clear the cached instance // [!code highlight]
+await closeWorld(); // close and clear the cached instance // [!code highlight]
const world = await getWorld(); // reinitialized with new configuration
```
@@ -46,4 +51,5 @@ const world = await getWorld(); // reinitialized with new configuration
## Related Functions
- [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the cached World instance.
-- [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) - Construct a fresh World from environment configuration.
+- [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) - Resolve a fresh World without using the cache.
+- [`closeWorld()`](/docs/api-reference/workflow-runtime/close-world) - Close and clear the managed World.
diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx
index efce612990..f23acfea8c 100644
--- a/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx
@@ -38,5 +38,5 @@ Returns a fetch-style request handler: `(req: Request) => Promise`.
## Related Functions
-- [`getWorldHandlers()`](/docs/api-reference/workflow-runtime/get-world-handlers) - The build-time World access this handler is built on.
+- [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the World used by the handler.
- [`healthCheck()`](/docs/api-reference/workflow-runtime/health-check) - Verify the entrypoint processes queue messages end-to-end.
diff --git a/docs/content/docs/v5/deploying/world/local-world.mdx b/docs/content/docs/v5/deploying/world/local-world.mdx
index 126bb3589f..1092226105 100644
--- a/docs/content/docs/v5/deploying/world/local-world.mdx
+++ b/docs/content/docs/v5/deploying/world/local-world.mdx
@@ -20,7 +20,9 @@ WORKFLOW_TARGET_WORLD=local
## Observability
-The `workflow` CLI uses the local world by default. Running these commands inside your workflow project will show your local development workflows:
+When no World is configured, the `workflow` CLI uses the local world by default.
+Running these commands inside your workflow project will show your local
+development workflows:
```bash
# List recent workflow runs
@@ -60,16 +62,21 @@ Maximum number of concurrent queue workers. Default: `100`
### Programmatic configuration
-{/* @skip-typecheck: incomplete code sample */}
-```typescript title="workflow.config.ts" lineNumbers
+Point `world` in `workflow.config.ts` at a provider module:
+
+```typescript title="workflow.world.ts" lineNumbers
+import type { WorldProvider } from "workflow/config";
import { createLocalWorld } from "@workflow/world-local";
-const world = createLocalWorld({
- dataDir: "./custom-workflow-data",
- port: 5173,
- // baseUrl overrides port if set
- baseUrl: "https://local.example.com:3000",
-});
+const world: WorldProvider = () =>
+ createLocalWorld({
+ dataDir: "./custom-workflow-data",
+ port: 5173,
+ // baseUrl overrides port if set
+ baseUrl: "https://local.example.com:3000",
+ });
+
+export default world;
```
## Limitations
@@ -81,4 +88,5 @@ The local world is designed for development, not production:
- **Single instance** - Cannot handle distributed deployments
- **No authentication** - Suitable only for local development
-For production deployments, use the [Vercel World](/worlds/vercel) or [Postgres World](/worlds/postgres).
+For production deployments, use the [Vercel World](/docs/deploying/world/vercel-world)
+or [Postgres World](/docs/deploying/world/postgres-world).
diff --git a/docs/content/docs/v5/deploying/world/postgres-world.mdx b/docs/content/docs/v5/deploying/world/postgres-world.mdx
index bc8f6a98c9..5300dcba93 100644
--- a/docs/content/docs/v5/deploying/world/postgres-world.mdx
+++ b/docs/content/docs/v5/deploying/world/postgres-world.mdx
@@ -22,11 +22,27 @@ Install the Postgres World package in your workflow project:
@workflow/world-postgres
```
-Configure the required environment variables to use the world and point it to your PostgreSQL database:
+Create a provider for the World:
-```bash title=".env"
-WORKFLOW_TARGET_WORLD="@workflow/world-postgres"
-WORKFLOW_POSTGRES_URL="postgres://user:password@host:5432/database"
+```typescript title="workflow.world.ts" lineNumbers
+import type { WorldProvider } from "workflow/config";
+import { createWorld } from "@workflow/world-postgres";
+
+const world: WorldProvider = () => createWorld();
+
+export default world;
+```
+
+Select it from your Workflow config:
+
+```typescript title="workflow.config.ts" lineNumbers
+import type { WorkflowConfig } from "workflow/config";
+
+const config = {
+ world: "./workflow.world.ts",
+} satisfies WorkflowConfig;
+
+export default config;
```
Run the migration script to create the necessary tables in your database. Ensure `WORKFLOW_POSTGRES_URL` is set when running this command:
@@ -41,85 +57,68 @@ The migration is idempotent and can safely be run as a post-deployment lifecycle
## Starting the World
-To subscribe to the graphile-worker queue, your workflow app needs to start the world on server start. Here are examples for a few frameworks:
+Workflow calls `start()` once, immediately before the configured World is first
+used. Because the Postgres worker must also recover queued work after a process
+restart, resolve it during server startup rather than waiting for the first
+request.
-Create an `instrumentation.ts` file in your project root:
-
-```ts title="instrumentation.ts" lineNumbers
+```typescript title="instrumentation.ts" lineNumbers
export async function register() {
if (process.env.NEXT_RUNTIME !== "edge") {
const { getWorld } = await import("workflow/runtime");
- const world = await getWorld();
- await world.start?.();
+ await getWorld();
}
}
```
-
-Learn more about [Next.js Instrumentation](https://nextjs.org/docs/app/guides/instrumentation).
-
-
-Create a `src/hooks.server.ts` file:
-
-```ts title="src/hooks.server.ts" lineNumbers
+```typescript title="src/hooks.server.ts" lineNumbers
import type { ServerInit } from "@sveltejs/kit";
export const init: ServerInit = async () => {
const { getWorld } = await import("workflow/runtime");
- const world = await getWorld();
- await world.start?.();
+ await getWorld();
};
```
-
-Learn more about [SvelteKit Hooks](https://svelte.dev/docs/kit/hooks).
-
-
-Create a plugin to start the world on server initialization:
-
-```ts title="plugins/start-pg-world.ts" lineNumbers
+```typescript title="plugins/start-postgres-world.ts" lineNumbers
import { defineNitroPlugin } from "nitro/~internal/runtime/plugin";
export default defineNitroPlugin(async () => {
const { getWorld } = await import("workflow/runtime");
- const world = await getWorld();
- await world.start?.();
+ await getWorld();
});
```
-Register the plugin in your config:
+```typescript title="nitro.config.ts" lineNumbers
+import { defineConfig } from "nitro";
-```ts title="nitro.config.ts"
-import { defineNitroConfig } from "nitropack";
-
-export default defineNitroConfig({
+export default defineConfig({
modules: ["workflow/nitro"],
- plugins: ["plugins/start-pg-world.ts"],
+ plugins: ["plugins/start-postgres-world.ts"],
});
```
-
-Learn more about [Nitro Plugins](https://v3.nitro.build/docs/plugins).
-
-
+If you construct a Postgres World outside a provider, call `start()` yourself
+during server initialization.
+
-The Postgres World requires a long-lived worker process that polls the database for jobs. This does not work on serverless environments. For Vercel deployments, use the [Vercel World](/worlds/vercel) instead.
+The Postgres World requires a long-lived worker process that polls the database for jobs. This does not work on serverless environments. For Vercel deployments, use the [Vercel World](/docs/deploying/world/vercel-world) instead.
## Observability
@@ -127,14 +126,11 @@ The Postgres World requires a long-lived worker process that polls the database
Use the `workflow` CLI to inspect workflows stored in PostgreSQL:
```bash
-# Set your database URL
-export WORKFLOW_POSTGRES_URL="postgres://user:password@host:5432/database"
-
# List workflow runs
-npx workflow inspect runs --backend @workflow/world-postgres
+npx workflow inspect runs
# Launch the web UI
-npx workflow web --backend @workflow/world-postgres
+npx workflow web
```
If `WORKFLOW_POSTGRES_URL` is not set, the CLI defaults to `postgres://world:world@localhost:5432/world`.
@@ -149,9 +145,9 @@ Learn more in the [Observability](/docs/observability) documentation.
All configuration options can be set via environment variables or programmatically via `createWorld()`.
-### `WORKFLOW_POSTGRES_URL` (required)
+### `WORKFLOW_POSTGRES_URL`
-PostgreSQL connection string. Falls back to `DATABASE_URL` if not set.
+PostgreSQL connection string.
Default: `postgres://world:world@localhost:5432/world`
@@ -171,21 +167,6 @@ Maximum size of the internal `pg.Pool` used when `createWorld()` constructs the
For higher worker concurrency, Graphile Worker recommends setting `maxPoolSize` to `10` or `queueConcurrency + 2`, whichever is larger.
-### Programmatic configuration
-
-{/*@skip-typecheck: incomplete code sample*/}
-
-```typescript title="workflow.config.ts" lineNumbers
-import { createWorld } from "@workflow/world-postgres";
-
-const world = createWorld({
- connectionString: "postgres://user:password@host:5432/database",
- jobPrefix: "myapp_",
- queueConcurrency: 50,
- maxPoolSize: 52, // overrides WORKFLOW_POSTGRES_MAX_POOL_SIZE
-});
-```
-
## How It Works
The Postgres World uses PostgreSQL as a durable backend:
@@ -209,16 +190,19 @@ Ensure your deployment has:
1. Network access to your PostgreSQL database
2. Environment variables configured correctly
-3. The `start()` function called on server initialization
+3. The provider selected in `workflow.config.ts`
-The Postgres World is not compatible with Vercel deployments. On Vercel, workflows automatically use the [Vercel World](/worlds/vercel) with zero configuration.
+The Postgres World is not compatible with Vercel deployments. Omit `world` to
+select the [Vercel World](/docs/deploying/world/vercel-world) automatically on Vercel, or select an
+appropriate World inside your provider based on the deployment environment.
## Limitations
-- **Requires long-running process** - Must call `start()` on server initialization; not compatible with serverless platforms
+- **Requires long-running process** - Configured providers start automatically; directly constructed Worlds require `start()` during server initialization
- **PostgreSQL infrastructure** - Requires a PostgreSQL database (self-hosted or managed)
-- **Not compatible with Vercel** - Use the [Vercel World](/worlds/vercel) for Vercel deployments
+- **Not compatible with Vercel** - Use the [Vercel World](/docs/deploying/world/vercel-world) for Vercel deployments
-For local development, use the [Local World](/worlds/local) which requires no external services.
+For local development, use the [Local World](/docs/deploying/world/local-world)
+which requires no external services.
diff --git a/docs/content/docs/v5/deploying/world/vercel-world.mdx b/docs/content/docs/v5/deploying/world/vercel-world.mdx
index 5bbb91fe69..ac4acdcb2f 100644
--- a/docs/content/docs/v5/deploying/world/vercel-world.mdx
+++ b/docs/content/docs/v5/deploying/world/vercel-world.mdx
@@ -40,7 +40,8 @@ For complete details on pricing, usage limits, and included allotments on Vercel
- **[Vercel limits](https://vercel.com/docs/limits)** — Platform-wide limits including Workflow-specific constraints
- **[Vercel Hobby plan](https://vercel.com/docs/plans/hobby)** — Free tier included usage for Workflow and other resources
-For self-hosted deployments, use the [Postgres World](/worlds/postgres). For local development, use the [Local World](/worlds/local).
+For self-hosted deployments, use the [Postgres World](/docs/deploying/world/postgres-world).
+For local development, use the [Local World](/docs/deploying/world/local-world).
## Limitations
@@ -91,42 +92,29 @@ Learn more in the [Observability](/docs/observability) documentation.
The Vercel World requires no configuration when deployed to Vercel. For advanced use cases, you can override settings programmatically via `createVercelWorld()`.
-### `WORKFLOW_VERCEL_ENV`
-
-The Vercel environment to use. Options: `production`, `preview`, `development`. Automatically detected.
-
-### `WORKFLOW_VERCEL_AUTH_TOKEN`
-
-Authentication token for API requests. Automatically detected.
-
-### `WORKFLOW_VERCEL_PROJECT`
-
-Vercel project ID for API requests. Automatically detected.
-
-### `WORKFLOW_VERCEL_TEAM`
-
-Vercel team ID for API requests. Automatically detected.
-
### `WORKFLOW_VERCEL_BACKEND_URL`
-Custom base URL for the Vercel workflow API. Automatically detected.
+Overrides the Vercel workflow API proxy URL.
### Programmatic configuration
-{/*@skip-typecheck: incomplete code sample*/}
+Point `world` in `workflow.config.ts` at a provider module:
-```typescript title="workflow.config.ts" lineNumbers
+```typescript title="workflow.world.ts" lineNumbers
+import type { WorldProvider } from "workflow/config";
import { createVercelWorld } from "@workflow/world-vercel";
-const world = createVercelWorld({
- token: process.env.WORKFLOW_VERCEL_AUTH_TOKEN,
- baseUrl: "https://api.vercel.com/v1/workflow",
- projectConfig: {
- projectId: "my-project",
- teamId: "my-team",
- environment: "production",
- },
-});
+const world: WorldProvider = () =>
+ createVercelWorld({
+ token: process.env.WORKFLOW_VERCEL_AUTH_TOKEN,
+ projectConfig: {
+ projectId: "my-project",
+ teamId: "my-team",
+ environment: "production",
+ },
+ });
+
+export default world;
```
## Versioning
diff --git a/docs/content/docs/v5/foundations/configuration.mdx b/docs/content/docs/v5/foundations/configuration.mdx
new file mode 100644
index 0000000000..01bf37beb7
--- /dev/null
+++ b/docs/content/docs/v5/foundations/configuration.mdx
@@ -0,0 +1,168 @@
+---
+title: Configuration
+description: Configure Workflow SDK with a typed workflow.config.ts file.
+type: conceptual
+summary: Configure the World, builds, queue namespace, and framework integration for your app.
+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,
+ },
+ queue: {
+ namespace: "myapp",
+ },
+} satisfies WorkflowConfig;
+
+export default config;
+```
+
+The World module default-exports a lazy provider. Read runtime credentials from
+environment variables here rather than placing them in the build configuration:
+
+```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 an optional path relative to `workflow.config.ts`, or a package
+specifier. Its module must default-export a `WorldProvider`. The module is
+included in the runtime output, but the provider runs only when the application
+first needs its World.
+
+World selection follows this order:
+
+| Condition | Selected World |
+| --- | --- |
+| `WORKFLOW_TARGET_WORLD` is set | The built-in target or module named by the environment variable |
+| `world` is configured | The configured provider |
+| `VERCEL_DEPLOYMENT_ID` is set | Vercel World |
+| Otherwise | Local World |
+
+You do not need a `workflow.world.ts` file when the automatic Local and Vercel
+Worlds are sufficient. A config file may also omit `world` while configuring
+build or queue settings.
+
+A missing configured module fails during build or development setup. If a
+provider throws or returns no World, runtime startup fails; Workflow does not
+silently switch to another backend.
+
+## Deployment Environments
+
+For local development and Vercel deployments, omit `world` to use the automatic
+Local and Vercel Worlds.
+
+When environments need different custom Worlds or options, select them inside
+the provider using an application environment variable set by each deployment.
+Use a dedicated variable when you need to distinguish preview from production,
+because both commonly use `NODE_ENV=production`.
+
+```typescript title="workflow.world.ts" lineNumbers
+import type { WorldProvider } from "workflow/config";
+import { createLocalWorld } from "@workflow/world-local";
+import { createWorld as createPostgresWorld } from "@workflow/world-postgres";
+
+const world: WorldProvider = () => {
+ switch (process.env.APP_ENV) {
+ case "production":
+ case "preview":
+ return createPostgresWorld({
+ connectionString: process.env.WORKFLOW_POSTGRES_URL!,
+ });
+ case "development":
+ case "test":
+ return createLocalWorld();
+ default:
+ throw new Error(
+ `Unexpected APP_ENV: ${process.env.APP_ENV}`,
+ );
+ }
+};
+
+export default world;
+```
+
+Set `WORKFLOW_POSTGRES_URL` independently in preview and production to keep
+credentials out of source control and point each deployment at the correct
+database.
+
+## Build
+
+| Option | Type | Description |
+| --- | --- | --- |
+| `build.dirs` | `string[]` | Directories to scan for workflow code |
+| `build.projectRoot` | `string` | Root for tracing, module resolution, and TypeScript configuration |
+| `build.externalPackages` | `string[]` | Packages excluded from generated bundles. Direct Vercel Build Output API builds bundle all dependencies. |
+| `build.sourcemap` | `boolean \| "inline" \| "linked" \| "external" \| "both"` | Source map mode for generated bundles |
+| `build.manifest.public` | `boolean` | Expose the workflow manifest over HTTP |
+| `build.manifest.output` | `string` | Write the workflow manifest to this path |
+
+## Queue Namespace
+
+`queue.namespace` prefixes Workflow queue topics and generated deployment
+triggers. It must be lowercase alphanumeric and start with a letter.
+
+For Postgres Worlds that share a database, also give each application a unique
+`jobPrefix`. The namespace distinguishes Workflow topics; `jobPrefix`
+distinguishes the underlying Graphile Worker tasks.
+
+## Integration Settings
+
+Use `integration` only for framework-specific settings. It is a discriminated
+object, so settings for different integrations cannot be mixed. Nitro currently
+supports integration settings:
+
+```typescript title="workflow.config.ts" lineNumbers
+import type { WorkflowConfig } from "workflow/config";
+
+const config = {
+ integration: {
+ type: "nitro",
+ typescriptPlugin: true,
+ runtime: "nodejs24.x",
+ },
+} satisfies WorkflowConfig;
+
+export default config;
+```
diff --git a/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/astro/package.json b/packages/astro/package.json
index 45083180eb..d7484bcd90 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -27,6 +27,7 @@
"dependencies": {
"@swc/core": "1.15.3",
"@workflow/builders": "workspace:*",
+ "@workflow/config": "workspace:*",
"@workflow/rollup": "workspace:*",
"@workflow/swc-plugin": "workspace:*",
"@workflow/vite": "workspace:*",
diff --git a/packages/astro/src/builder.ts b/packages/astro/src/builder.ts
index 4987acbe72..cc50ed9fd3 100644
--- a/packages/astro/src/builder.ts
+++ b/packages/astro/src/builder.ts
@@ -3,7 +3,6 @@ import { join, resolve } from 'node:path';
import {
type AstroConfig,
BaseBuilder,
- createBaseBuilderConfig,
NORMALIZE_REQUEST_CODE,
VercelBuildOutputAPIBuilder,
} from '@workflow/builders';
@@ -20,18 +19,16 @@ const WORKFLOW_ROUTES = [
];
export class LocalBuilder extends BaseBuilder {
- constructor(options?: {
- sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both';
- }) {
+ constructor(config: Partial = {}) {
+ const workingDir = config.workingDir ?? process.cwd();
+ const build = config.workflowConfig?.config.build;
+
super({
- dirs: ['src/pages', 'src/workflows'],
+ ...config,
+ dirs: config.dirs ?? build?.dirs ?? ['src/pages', 'src/workflows'],
buildTarget: 'astro' as const,
- stepsBundlePath: '', // unused in base
- workflowsBundlePath: '', // unused in base
- webhookBundlePath: '', // unused in base
- workingDir: process.cwd(),
+ workingDir,
debugFilePrefix: '_', // Prefix with underscore so Astro ignores debug files
- sourcemap: options?.sourcemap,
});
}
@@ -94,9 +91,11 @@ export const prerender = false;`
// Expose manifest as a public HTTP route when WORKFLOW_PUBLIC_MANIFEST=1
// Astro maps `foo.json.js` to the URL `/foo.json`
+ const publicManifestPath = join(workflowGeneratedDir, 'manifest.json.js');
+ await rm(publicManifestPath, { force: true });
if (this.shouldExposePublicManifest && manifestJson) {
await writeFile(
- join(workflowGeneratedDir, 'manifest.json.js'),
+ publicManifestPath,
`export function GET() {
return new Response(${JSON.stringify(manifestJson)}, {
headers: { "content-type": "application/json" },
@@ -162,15 +161,13 @@ export const prerender = false;`
}
export class VercelBuilder extends VercelBuildOutputAPIBuilder {
- constructor(config?: Partial) {
- const workingDir = config?.workingDir || process.cwd();
+ constructor(config: Partial = {}) {
+ const workingDir = config.workingDir ?? process.cwd();
+ const build = config.workflowConfig?.config.build;
super({
- ...createBaseBuilderConfig({
- workingDir,
- dirs: ['src/pages', 'src/workflows'],
- runtime: config?.runtime,
- sourcemap: config?.sourcemap,
- }),
+ ...config,
+ workingDir,
+ dirs: config.dirs ?? build?.dirs ?? ['src/pages', 'src/workflows'],
buildTarget: 'vercel-build-output-api',
debugFilePrefix: '_',
});
diff --git a/packages/astro/src/plugin.ts b/packages/astro/src/plugin.ts
index 968f5b26c4..ad8bfcbf8b 100644
--- a/packages/astro/src/plugin.ts
+++ b/packages/astro/src/plugin.ts
@@ -1,4 +1,10 @@
+import { fileURLToPath } from 'node:url';
import { createBuildQueue } from '@workflow/builders';
+import type { SourcemapMode } from '@workflow/config';
+import {
+ type LoadedWorkflowConfig,
+ loadWorkflowConfig,
+} from '@workflow/config/load';
import { workflowTransformPlugin } from '@workflow/rollup';
import { workflowHotUpdatePlugin } from '@workflow/vite';
import type { AstroIntegration, HookParameters } from 'astro';
@@ -11,39 +17,60 @@ export interface WorkflowPluginOptions {
* `'linked'`, `'external'`, `'both'`, or `false` to omit source maps. Can
* also be set via the `WORKFLOW_SOURCEMAP` environment variable.
*/
- sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both';
+ sourcemap?: SourcemapMode;
}
export function workflowPlugin(
options: WorkflowPluginOptions = {}
): AstroIntegration {
- const builder = new LocalBuilder({ sourcemap: options.sourcemap });
+ let builderConfig: {
+ workingDir: string;
+ workflowConfig: LoadedWorkflowConfig;
+ };
+ let builder: LocalBuilder;
const enqueue = createBuildQueue();
return {
name: 'workflow:astro',
hooks: {
'astro:config:setup': async ({
+ config,
updateConfig,
}: HookParameters<'astro:config:setup'>) => {
- // Use local builder
+ const workingDir = fileURLToPath(config.root);
+ builderConfig = {
+ workingDir,
+ workflowConfig: await loadWorkflowConfig({
+ cwd: workingDir,
+ integration: 'astro',
+ }),
+ };
+ builder = new LocalBuilder({
+ ...builderConfig,
+ sourcemap: options.sourcemap,
+ });
if (!process.env.VERCEL_DEPLOYMENT_ID) {
- try {
- await builder.build();
- } catch (buildError) {
- // Build might fail due to invalid workflow files or missing dependencies
- // Log the error and rethrow to properly propagate to Astro
- console.error('Build failed during config setup:', buildError);
- throw buildError;
- }
+ await builder.build();
}
updateConfig({
vite: {
+ ...(builderConfig.workflowConfig.runtimePath
+ ? { ssr: { noExternal: ['workflow', '@workflow/core'] } }
+ : {}),
plugins: [
workflowTransformPlugin(),
+ {
+ name: 'workflow:runtime-config',
+ enforce: 'pre',
+ resolveId(source) {
+ if (source === '@workflow/config/runtime-binding') {
+ return builderConfig.workflowConfig.runtimePath;
+ }
+ },
+ },
// Cast needed due to Astro using a different internal Vite version
workflowHotUpdatePlugin({
- builder,
+ builder: () => builder,
enqueue,
}) as any,
],
@@ -53,6 +80,7 @@ export function workflowPlugin(
'astro:build:done': async () => {
if (process.env.VERCEL_DEPLOYMENT_ID) {
const vercelBuilder = new VercelBuilder({
+ ...builderConfig,
sourcemap: options.sourcemap,
});
await vercelBuilder.build();
diff --git a/packages/builders/package.json b/packages/builders/package.json
index 5ca459fa6a..0715147b55 100644
--- a/packages/builders/package.json
+++ b/packages/builders/package.json
@@ -40,6 +40,7 @@
},
"dependencies": {
"@swc/core": "catalog:",
+ "@workflow/config": "workspace:*",
"@workflow/core": "workspace:*",
"@workflow/errors": "workspace:*",
"@workflow/utils": "workspace:*",
diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts
index c8ac9a9781..a738f2a9d3 100644
--- a/packages/builders/src/base-builder.ts
+++ b/packages/builders/src/base-builder.ts
@@ -37,7 +37,7 @@ import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js';
import { createSwcPlugin } from './swc-esbuild-plugin.js';
import { detectWorkflowPatterns } from './transform-utils.js';
-import type { SourcemapMode, WorkflowConfig } from './types.js';
+import type { BuilderConfig, SourcemapMode } from './types.js';
import { extractWorkflowGraphs } from './workflows-extractor.js';
const enhancedResolve = promisify(enhancedResolveOriginal);
@@ -216,8 +216,10 @@ function mergeWorkflowManifest(
*
* Subclasses must implement the build() method to define builder-specific logic.
*/
-export abstract class BaseBuilder {
- protected config: WorkflowConfig;
+export abstract class BaseBuilder<
+ TConfig extends BuilderConfig = BuilderConfig,
+> {
+ protected config: TConfig;
/**
* Tracks which external packages have already been warned about
@@ -227,18 +229,55 @@ export abstract class BaseBuilder {
private workflowBuildStartTime: number | undefined;
private workflowBuildSummaryCount = 0;
- constructor(config: WorkflowConfig) {
+ constructor(config: TConfig) {
this.config = config;
}
protected get transformProjectRoot(): string {
- return this.config.projectRoot || this.config.workingDir;
+ const projectRoot =
+ this.config.projectRoot ??
+ this.config.workflowConfig?.config.build?.projectRoot;
+ return projectRoot
+ ? resolve(this.config.workingDir, projectRoot)
+ : this.config.workingDir;
}
protected get moduleSpecifierRoot(): string {
return this.config.moduleSpecifierRoot || this.transformProjectRoot;
}
+ protected get queueNamespace(): string | undefined {
+ return (
+ process.env.WORKFLOW_QUEUE_NAMESPACE ??
+ this.config.workflowConfig?.config.queue?.namespace
+ );
+ }
+
+ protected get externalPackages(): string[] {
+ if (this.config.buildTarget === 'vercel-build-output-api') return [];
+ return (
+ this.config.externalPackages ??
+ this.config.workflowConfig?.config.build?.externalPackages ??
+ []
+ );
+ }
+
+ private get runtimeConfigPlugins(): esbuild.Plugin[] {
+ const path = this.config.workflowConfig?.runtimePath;
+ if (!path) return [];
+ return [
+ {
+ name: 'workflow-runtime-config',
+ setup(build) {
+ build.onResolve(
+ { filter: /^@workflow\/config\/runtime-binding$/ },
+ () => ({ path })
+ );
+ },
+ },
+ ];
+ }
+
protected logBaseBuilderInfo(...args: unknown[]): void {
buildLogger.debug(args.map(String).join(' '));
}
@@ -401,8 +440,8 @@ export abstract class BaseBuilder {
* workflow compiler when the package is externalized.
*/
private async warnAboutExternalWorkflowPackages(): Promise {
- const externalPackages = this.config.externalPackages;
- if (!externalPackages?.length) return;
+ const externalPackages = this.externalPackages;
+ if (!externalPackages.length) return;
for (const pkg of externalPackages) {
if (BaseBuilder.PSEUDO_PACKAGES.has(pkg)) continue;
@@ -1107,7 +1146,7 @@ export const __steps_registered = true;
],
// Plugin should catch most things, but this lets users hard override
// if the plugin misses anything that should be externalized
- external: ['bun', 'bun:*', ...(this.config.externalPackages || [])],
+ external: ['bun', 'bun:*', ...this.externalPackages],
});
const stepsResult = await esbuildCtx.rebuild();
@@ -1371,11 +1410,11 @@ export const __steps_registered = true;
`${Date.now() - bundleStartTime}ms`
);
- if (this.config.workflowManifestPath) {
- const resolvedPath = resolve(
- process.cwd(),
- this.config.workflowManifestPath
- );
+ const workflowManifestPath =
+ this.config.workflowManifestPath ??
+ this.config.workflowConfig?.config.build?.manifest?.output;
+ if (workflowManifestPath) {
+ const resolvedPath = this.resolvePath(workflowManifestPath);
let prefix = '';
if (resolvedPath.endsWith('.cjs')) {
@@ -1446,6 +1485,7 @@ export const __steps_registered = true;
const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode(
{
+ namespace: this.queueNamespace,
routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt',
}
);
@@ -1502,7 +1542,11 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
write: true,
keepNames: true,
minify: false,
- external: ['@aws-sdk/credential-provider-web-identity'],
+ external: [
+ '@aws-sdk/credential-provider-web-identity',
+ ...this.externalPackages,
+ ],
+ plugins: this.runtimeConfigPlugins,
});
this.logEsbuildMessages(
@@ -1641,6 +1685,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
const stepsRelativePath = `./${basename(stepsOutfile).replace(/\\/g, '/')}`;
const escapedVMCode = workflowVMCode.replace(/[\\`$]/g, '\\$&');
const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode({
+ namespace: this.queueNamespace,
routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt',
});
@@ -1689,7 +1734,11 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
keepNames: true,
minify: false,
define: importMetaDefine,
- external: ['@aws-sdk/credential-provider-web-identity'],
+ external: [
+ '@aws-sdk/credential-provider-web-identity',
+ ...this.externalPackages,
+ ],
+ plugins: this.runtimeConfigPlugins,
});
this.logEsbuildMessages(finalResult, 'combined bundle', true);
this.logBaseBuilderInfo(
@@ -1716,6 +1765,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
const escaped = interimBundleText.replace(/[\\`$]/g, '\\$&');
const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode(
{
+ namespace: this.queueNamespace,
routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt',
}
);
@@ -1970,8 +2020,8 @@ export const OPTIONS = handler;`;
],
sourcemap: this.resolveSourcemap(EMIT_SOURCEMAPS_FOR_DEBUGGING),
mainFields: ['module', 'main'],
- // Don't externalize anything - bundle everything including workflow packages
- external: [],
+ external: this.externalPackages,
+ plugins: this.runtimeConfigPlugins,
});
this.logEsbuildMessages(result, 'webhook bundle creation');
@@ -2108,10 +2158,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;
}
/**
@@ -2160,14 +2214,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..40a9473e01 100644
--- a/packages/builders/src/config-helpers.ts
+++ b/packages/builders/src/config-helpers.ts
@@ -1,7 +1,7 @@
import { readFile } from 'node:fs/promises';
import { findUp } from 'find-up';
import JSON5 from 'json5';
-import type { SourcemapMode, WorkflowConfig } from './types.js';
+import type { BaseBuilderConfig } from './types.js';
export interface DecoratorOptions {
decorators: boolean;
@@ -85,29 +85,8 @@ export async function getDecoratorOptionsForDirectoryWithConfigPath(
return { options, configPath };
}
-/**
- * Creates a partial configuration for builders that don't use bundle paths directly.
- * Used by framework integrations like Nitro where the builder computes paths internally.
- */
-export function createBaseBuilderConfig(options: {
- workingDir: string;
- projectRoot?: string;
- dirs?: string[];
- watch?: boolean;
- externalPackages?: string[];
- runtime?: string;
- sourcemap?: SourcemapMode;
-}): Omit {
- return {
- dirs: options.dirs ?? ['workflows'],
- projectRoot: options.projectRoot,
- workingDir: options.workingDir,
- watch: options.watch,
- stepsBundlePath: '', // Not used by base builder methods
- workflowsBundlePath: '', // Not used by base builder methods
- webhookBundlePath: '', // Not used by base builder methods
- externalPackages: options.externalPackages,
- runtime: options.runtime,
- sourcemap: options.sourcemap,
- };
+export function createBaseBuilderConfig(
+ config: BaseBuilderConfig
+): BaseBuilderConfig {
+ return config;
}
diff --git a/packages/builders/src/constants.test.ts b/packages/builders/src/constants.test.ts
index 3e086100f9..c92ee69aab 100644
--- a/packages/builders/src/constants.test.ts
+++ b/packages/builders/src/constants.test.ts
@@ -37,7 +37,7 @@ describe('createWorkflowEntrypointOptionsCode', () => {
it('inlines an explicit namespace', () => {
expect(createWorkflowEntrypointOptionsCode({ namespace: 'custom' })).toBe(
- ', { namespace: "custom" }'
+ ', { namespace: process.env.WORKFLOW_QUEUE_NAMESPACE ?? "custom" }'
);
});
@@ -45,7 +45,7 @@ describe('createWorkflowEntrypointOptionsCode', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';
expect(createWorkflowEntrypointOptionsCode()).toBe(
- ', { namespace: "custom" }'
+ ', { namespace: process.env.WORKFLOW_QUEUE_NAMESPACE ?? "custom" }'
);
});
@@ -56,7 +56,7 @@ describe('createWorkflowEntrypointOptionsCode', () => {
routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt',
})
).toBe(
- ', { namespace: "custom", routeModuleBodyStartedAt: workflowRouteModuleBodyStartedAt }'
+ ', { namespace: process.env.WORKFLOW_QUEUE_NAMESPACE ?? "custom", routeModuleBodyStartedAt: workflowRouteModuleBodyStartedAt }'
);
});
});
diff --git a/packages/builders/src/constants.ts b/packages/builders/src/constants.ts
index 7d063890e8..3391b3099a 100644
--- a/packages/builders/src/constants.ts
+++ b/packages/builders/src/constants.ts
@@ -49,8 +49,7 @@ export function createWorkflowQueueTrigger(options?: { namespace?: string }) {
/**
* Creates the optional second argument for generated `workflowEntrypoint()`
- * calls. The namespace is resolved while building so generated route files do
- * not need `WORKFLOW_QUEUE_NAMESPACE` at runtime.
+ * calls. Runtime environment variables override the build-time fallback.
*/
export function createWorkflowEntrypointOptionsCode(options?: {
namespace?: string;
@@ -63,7 +62,9 @@ export function createWorkflowEntrypointOptionsCode(options?: {
if (namespace) {
// Reuse prefix construction for namespace validation.
getQueueTopicPrefix('workflow', namespace);
- fields.push(`namespace: ${JSON.stringify(namespace)}`);
+ fields.push(
+ `namespace: process.env.WORKFLOW_QUEUE_NAMESPACE ?? ${JSON.stringify(namespace)}`
+ );
}
if (options?.routeModuleBodyStartedAt) {
diff --git a/packages/builders/src/get-input-files.test.ts b/packages/builders/src/get-input-files.test.ts
index a284a1dadc..2afd8c9d7e 100644
--- a/packages/builders/src/get-input-files.test.ts
+++ b/packages/builders/src/get-input-files.test.ts
@@ -274,9 +274,6 @@ describe('getDiagnosticsManifestPath', () => {
buildTarget: 'vercel-build-output-api',
workingDir: testRoot,
dirs: ['src'],
- stepsBundlePath: join(testRoot, 'steps.js'),
- workflowsBundlePath: join(testRoot, 'workflows.js'),
- webhookBundlePath: join(testRoot, 'webhook.js'),
};
const builder = new TestBuilder(config);
diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts
index a1ec9443ea..27299b3ef5 100644
--- a/packages/builders/src/index.ts
+++ b/packages/builders/src/index.ts
@@ -50,6 +50,8 @@ export {
} from './transform-utils.js';
export type {
AstroConfig,
+ BaseBuilderConfig,
+ BuilderConfig,
BuildTarget,
NextConfig,
StandaloneConfig,
diff --git a/packages/builders/src/resolve-sourcemap.test.ts b/packages/builders/src/resolve-sourcemap.test.ts
index 774e8a7a36..83df9d60c4 100644
--- a/packages/builders/src/resolve-sourcemap.test.ts
+++ b/packages/builders/src/resolve-sourcemap.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { BaseBuilder } from './base-builder.js';
-import type { SourcemapMode, StandaloneConfig } from './types.js';
+import type { NextConfig, SourcemapMode } from './types.js';
/**
* Minimal subclass that exposes the protected `resolveSourcemap()` and
@@ -30,17 +30,22 @@ class TestBuilder extends BaseBuilder {
function createBuilder(
sourcemap?: SourcemapMode,
- watch?: boolean
+ options: { watch?: boolean; workflowSourcemap?: SourcemapMode } = {}
): TestBuilder {
- const config: StandaloneConfig = {
- buildTarget: 'standalone',
+ const config: NextConfig = {
+ buildTarget: 'next',
workingDir: '/tmp/workflow-test',
dirs: ['.'],
- stepsBundlePath: '',
- workflowsBundlePath: '',
- webhookBundlePath: '',
sourcemap,
- watch,
+ 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 +89,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 +166,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 +208,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/standalone.ts b/packages/builders/src/standalone.ts
index 1c9db58487..a6a9939410 100644
--- a/packages/builders/src/standalone.ts
+++ b/packages/builders/src/standalone.ts
@@ -1,14 +1,7 @@
import { BaseBuilder } from './base-builder.js';
-import type { WorkflowConfig } from './types.js';
-
-export class StandaloneBuilder extends BaseBuilder {
- constructor(config: WorkflowConfig) {
- super({
- ...config,
- dirs: ['.'],
- });
- }
+import type { StandaloneConfig } from './types.js';
+export class StandaloneBuilder extends BaseBuilder {
async build(): Promise {
const inputFiles = await this.getInputFiles();
const tsconfigPath = await this.findTsConfigPath();
@@ -53,6 +46,7 @@ export class StandaloneBuilder extends BaseBuilder {
await this.createWebhookBundle({
outfile: webhookBundlePath,
+ bundle: true,
});
}
}
diff --git a/packages/builders/src/types.ts b/packages/builders/src/types.ts
index 74fcd3b885..2fc134fb0d 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,22 +13,10 @@ 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.
*/
-interface BaseWorkflowConfig {
+export interface BaseBuilderConfig {
watch?: boolean;
dirs: string[];
workingDir: string;
@@ -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;
}
@@ -99,7 +95,7 @@ interface BaseWorkflowConfig {
/**
* Configuration for standalone (CLI-based) builds.
*/
-export interface StandaloneConfig extends BaseWorkflowConfig {
+export interface StandaloneConfig extends BaseBuilderConfig {
buildTarget: 'standalone';
stepsBundlePath: string;
workflowsBundlePath: string;
@@ -109,61 +105,42 @@ export interface StandaloneConfig extends BaseWorkflowConfig {
/**
* Configuration for Vercel Build Output API builds.
*/
-export interface VercelBuildOutputConfig extends BaseWorkflowConfig {
+export interface VercelBuildOutputConfig extends BaseBuilderConfig {
buildTarget: 'vercel-build-output-api';
- stepsBundlePath: string;
- workflowsBundlePath: string;
- webhookBundlePath: string;
}
/**
* Configuration for Next.js builds.
*/
-export interface NextConfig extends BaseWorkflowConfig {
+export interface NextConfig extends BaseBuilderConfig {
buildTarget: 'next';
- // Next.js builder computes paths dynamically, so these are not used
- stepsBundlePath: string;
- workflowsBundlePath: string;
- webhookBundlePath: string;
}
/**
* Configuration for SvelteKit builds.
*/
-export interface SvelteKitConfig extends BaseWorkflowConfig {
+export interface SvelteKitConfig extends BaseBuilderConfig {
buildTarget: 'sveltekit';
- // SvelteKit builder computes paths dynamically, so these are not used
- stepsBundlePath: string;
- workflowsBundlePath: string;
- webhookBundlePath: string;
}
/**
* Configuration for Astro builds.
*/
-export interface AstroConfig extends BaseWorkflowConfig {
+export interface AstroConfig extends BaseBuilderConfig {
buildTarget: 'astro';
- // Astro builder computes paths dynamically, so these are not used
- stepsBundlePath: string;
- workflowsBundlePath: string;
- webhookBundlePath: string;
}
/**
* Configuration for NestJS builds.
*/
-export interface NestConfig extends BaseWorkflowConfig {
+export interface NestConfig extends BaseBuilderConfig {
buildTarget: 'nest';
- // NestJS builder computes paths dynamically, so these are not used
- stepsBundlePath: string;
- workflowsBundlePath: string;
- webhookBundlePath: string;
}
/**
* Discriminated union of all builder configuration types.
*/
-export type WorkflowConfig =
+export type BuilderConfig =
| StandaloneConfig
| VercelBuildOutputConfig
| NextConfig
@@ -171,6 +148,8 @@ export type WorkflowConfig =
| SvelteKitConfig
| AstroConfig;
+export type WorkflowConfig = BuilderConfig;
+
export function isValidBuildTarget(
target: string | undefined
): target is BuildTarget {
diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts
index ea21569a6c..4863554633 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 { copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { BaseBuilder } from './base-builder.js';
-import { WORKFLOW_QUEUE_TRIGGER } from './constants.js';
+import { createWorkflowQueueTrigger } from './constants.js';
export class VercelBuildOutputAPIBuilder extends BaseBuilder {
async build(): Promise {
@@ -38,7 +38,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,
});
@@ -58,11 +60,9 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {
// Expose manifest as a static file when WORKFLOW_PUBLIC_MANIFEST=1.
// Vercel Build Output API serves static files from .vercel/output/static/
+ const staticManifestDir = join(outputDir, 'static/.well-known/workflow/v1');
+ await rm(join(staticManifestDir, 'manifest.json'), { force: true });
if (this.shouldExposePublicManifest && manifestJson) {
- const staticManifestDir = join(
- outputDir,
- 'static/.well-known/workflow/v1'
- );
await mkdir(staticManifestDir, { recursive: true });
if (process.env.VERCEL_DEPLOYMENT_ID === undefined) {
await writeFile(join(staticManifestDir, '.gitignore'), '*');
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 4b362cd8c1..f4f992a412 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..0b888e07ac 100644
--- a/packages/cli/src/commands/build.ts
+++ b/packages/cli/src/commands/build.ts
@@ -4,7 +4,6 @@ import {
VercelBuildOutputAPIBuilder,
} from '@workflow/builders';
import { BaseCommand } from '../base.js';
-import { type BuildTarget, isValidBuildTarget } from '../lib/config/types.js';
import { getWorkflowConfig } from '../lib/config/workflow-config.js';
export default class Build extends BaseCommand {
@@ -26,7 +25,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',
}),
};
@@ -59,7 +60,10 @@ export default class Build extends BaseCommand {
}
// Validate build target
- if (!isValidBuildTarget(buildTarget)) {
+ if (
+ buildTarget !== 'standalone' &&
+ buildTarget !== 'vercel-build-output-api'
+ ) {
this.logWarn(
`Invalid target "${buildTarget}". Using default "standalone".`
);
@@ -67,11 +71,16 @@ export default class Build extends BaseCommand {
buildTarget = 'standalone';
}
- this.logInfo(`Using target: ${buildTarget}`);
+ const target =
+ buildTarget === 'vercel-build-output-api'
+ ? 'vercel-build-output-api'
+ : 'standalone';
+ this.logInfo(`Using target: ${target}`);
- const config = getWorkflowConfig({
- buildTarget: buildTarget as BuildTarget,
+ const config = await getWorkflowConfig({
+ buildTarget: target,
workflowManifest: flags['workflow-manifest'],
+ configFile: flags.config,
});
try {
diff --git a/packages/cli/src/commands/health.ts b/packages/cli/src/commands/health.ts
index c4249dd010..7c9ce019c6 100644
--- a/packages/cli/src/commands/health.ts
+++ b/packages/cli/src/commands/health.ts
@@ -162,7 +162,7 @@ async function verifyLocalServerAccessible(
);
}
-function isLocalBackend(backend: string): boolean {
+function isLocalBackend(backend: string | undefined): boolean {
return backend === 'local' || backend === '@workflow/world-local';
}
@@ -263,43 +263,29 @@ export default class Health extends BaseCommand {
public async run(): Promise {
const { flags } = await this.parse(Health);
- // For local backend, set up port configuration early
- if (isLocalBackend(flags.backend)) {
- // If user specifies a port, set the env var so the World uses it
- if (flags.port) {
- process.env.WORKFLOW_LOCAL_BASE_URL = `http://localhost:${flags.port}`;
- }
- // Set default WORKFLOW_LOCAL_BASE_URL if not already set
- // We use WORKFLOW_LOCAL_BASE_URL instead of PORT to avoid conflicts
- // with other tools (like Next.js) that also use the PORT env var
- if (!process.env.WORKFLOW_LOCAL_BASE_URL && !process.env.PORT) {
- process.env.WORKFLOW_LOCAL_BASE_URL = `http://localhost:${DEFAULT_LOCAL_PORT}`;
- }
+ const world = await setupCliWorld(flags, this.config.version);
+ if (!world) {
+ throw new Error(
+ 'Failed to connect to backend. Check your configuration.'
+ );
+ }
- // Verify the server is accessible before proceeding
+ const backend =
+ flags.backend ?? process.env.WORKFLOW_TARGET_WORLD ?? 'configured World';
+ if (isLocalBackend(backend)) {
const accessible = await this.verifyLocalServer(
flags.json,
flags.verbose,
flags.port
);
- if (!accessible) {
- process.exit(1);
- }
- }
-
- const world = await setupCliWorld(flags, this.config.version);
- if (!world) {
- throw new Error(
- 'Failed to connect to backend. Check your configuration.'
- );
+ if (!accessible) process.exit(1);
}
const { healthCheck } = await import('@workflow/core/runtime');
const endpoints = getEndpointsToCheck(flags.endpoint);
if (!flags.json) {
- const backendName =
- flags.backend === 'local' ? 'local server' : flags.backend;
+ const backendName = backend === 'local' ? 'local server' : backend;
logger.log(
chalk.gray(`Running queue-based health check against ${backendName}...`)
);
@@ -312,7 +298,7 @@ export default class Health extends BaseCommand {
verbose: flags.verbose,
});
- this.outputResults(results, flags.json, flags.backend);
+ this.outputResults(results, flags.json, backend);
const allHealthy = results.every((r) => r.healthy);
process.exit(allHealthy ? 0 : 1);
diff --git a/packages/cli/src/lib/config/workflow-config.test.ts b/packages/cli/src/lib/config/workflow-config.test.ts
new file mode 100644
index 0000000000..d4ebfdb07b
--- /dev/null
+++ b/packages/cli/src/lib/config/workflow-config.test.ts
@@ -0,0 +1,34 @@
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import { getWorkflowConfig } from './workflow-config.js';
+
+describe('getWorkflowConfig', () => {
+ const originalCwd = process.env.WORKFLOW_OBSERVABILITY_CWD;
+ const workingDir = mkdtempSync(join(tmpdir(), 'workflow-cli-config-'));
+
+ afterEach(() => {
+ if (originalCwd === undefined) {
+ delete process.env.WORKFLOW_OBSERVABILITY_CWD;
+ } else {
+ process.env.WORKFLOW_OBSERVABILITY_CWD = originalCwd;
+ }
+ rmSync(workingDir, { recursive: true, force: true });
+ });
+
+ it('scans the project by default and honors configured directories', async () => {
+ process.env.WORKFLOW_OBSERVABILITY_CWD = workingDir;
+ expect(
+ (await getWorkflowConfig({ buildTarget: 'standalone' })).dirs
+ ).toEqual(['.']);
+
+ writeFileSync(
+ join(workingDir, 'workflow.config.ts'),
+ `export default { build: { dirs: ['jobs'] } };`
+ );
+ expect(
+ (await getWorkflowConfig({ buildTarget: 'standalone' })).dirs
+ ).toEqual(['jobs']);
+ });
+});
diff --git a/packages/cli/src/lib/config/workflow-config.ts b/packages/cli/src/lib/config/workflow-config.ts
index 1ea91cc4cf..1175d27145 100644
--- a/packages/cli/src/lib/config/workflow-config.ts
+++ b/packages/cli/src/lib/config/workflow-config.ts
@@ -1,7 +1,17 @@
-import type { BuildTarget, WorkflowConfig } from './types.js';
import { resolve } from 'node:path';
+import {
+ type LoadedWorkflowConfig,
+ loadWorkflowConfig,
+} from '@workflow/config/load';
+import { config as loadDotEnv } from 'dotenv';
+import type { BuildTarget, WorkflowConfig } from './types.js';
+
+type CliBuildTarget = Extract<
+ BuildTarget,
+ 'standalone' | 'vercel-build-output-api'
+>;
-function resolveObservabilityCwd(): string {
+export function resolveWorkflowCwd(): string {
const raw = process.env.WORKFLOW_OBSERVABILITY_CWD;
if (!raw) {
return process.cwd();
@@ -11,28 +21,41 @@ function resolveObservabilityCwd(): string {
return resolve(process.cwd(), raw);
}
-export const getWorkflowConfig = (
- {
- buildTarget,
- workflowManifest,
- }: {
- buildTarget?: BuildTarget;
- workflowManifest?: string;
- } = {
- buildTarget: 'standalone',
- }
-) => {
- const config: WorkflowConfig = {
- dirs: ['./workflows'],
- workingDir: resolveObservabilityCwd(),
- buildTarget: buildTarget as BuildTarget,
- stepsBundlePath: './.well-known/workflow/v1/step.mjs',
- workflowsBundlePath: './.well-known/workflow/v1/flow.mjs',
- webhookBundlePath: './.well-known/workflow/v1/webhook.mjs',
- workflowManifestPath: workflowManifest,
+export async function loadProjectWorkflowConfig(
+ configFile?: string
+): Promise {
+ const cwd = resolveWorkflowCwd();
+ loadDotEnv({ path: resolve(cwd, '.env.local'), quiet: true });
+ loadDotEnv({ path: resolve(cwd, '.env'), quiet: true });
+ return loadWorkflowConfig({ cwd, configFile });
+}
- // WIP: generate a client library to easily execute workflows/steps
- // clientBundlePath: './lib/generated/workflows.js',
+export const getWorkflowConfig = async (options: {
+ buildTarget: CliBuildTarget;
+ workflowManifest?: string;
+ configFile?: string;
+}): Promise => {
+ const { buildTarget, workflowManifest, configFile } = options;
+ const workingDir = resolveWorkflowCwd();
+ const loadedConfig = await loadProjectWorkflowConfig(configFile);
+ const fileConfig = loadedConfig.config;
+ const config = {
+ dirs:
+ fileConfig.build?.dirs ??
+ (buildTarget === 'standalone' ? ['.'] : ['./workflows']),
+ workingDir,
+ workflowConfig: loadedConfig,
+ workflowManifestPath: workflowManifest,
};
- return config;
+ if (buildTarget === 'standalone') {
+ return {
+ ...config,
+ buildTarget,
+ stepsBundlePath: './.well-known/workflow/v1/step.mjs',
+ workflowsBundlePath: './.well-known/workflow/v1/flow.mjs',
+ webhookBundlePath: './.well-known/workflow/v1/webhook.mjs',
+ };
+ }
+
+ return { ...config, buildTarget };
};
diff --git a/packages/cli/src/lib/inspect/env.ts b/packages/cli/src/lib/inspect/env.ts
index a659e81785..25d68bc71f 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 {
@@ -20,7 +20,7 @@ import {
* Note: WORKFLOW_VERCEL_* env vars are read back via getEnvVars() and passed
* to createVercelWorld() explicitly — they are NOT read by createWorld().
*/
-export const writeEnvVars = (envVars: Record) => {
+export const writeEnvVars = (envVars: Record) => {
Object.entries(envVars).forEach(([key, value]) => {
if (
value === undefined ||
@@ -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/cli/src/lib/inspect/flags.test.ts b/packages/cli/src/lib/inspect/flags.test.ts
new file mode 100644
index 0000000000..988ef9129d
--- /dev/null
+++ b/packages/cli/src/lib/inspect/flags.test.ts
@@ -0,0 +1,12 @@
+import { Parser } from '@oclif/core';
+import { expect, it } from 'vitest';
+import { cliFlags } from './flags.js';
+
+it('parses without an explicit backend', async () => {
+ const { flags } = await Parser.parse([], { flags: cliFlags });
+
+ expect(flags.backend).toBeUndefined();
+ expect(flags.authToken).toBeUndefined();
+ expect(flags.project).toBeUndefined();
+ expect(flags.team).toBeUndefined();
+});
diff --git a/packages/cli/src/lib/inspect/flags.ts b/packages/cli/src/lib/inspect/flags.ts
index 44fa08c767..466c9e244d 100644
--- a/packages/cli/src/lib/inspect/flags.ts
+++ b/packages/cli/src/lib/inspect/flags.ts
@@ -30,7 +30,6 @@ export const cliFlags = {
description: 'backend to inspect',
required: false,
char: 'b',
- default: 'local',
env: 'WORKFLOW_TARGET_WORLD',
helpGroup: 'Target',
helpLabel: '-b, --backend',
@@ -43,7 +42,6 @@ export const cliFlags = {
required: false,
char: 'a',
dependsOn: ['backend'],
- default: '',
env: 'WORKFLOW_VERCEL_AUTH_TOKEN',
helpGroup: 'Target',
helpLabel: '-a, --authToken',
@@ -53,7 +51,6 @@ export const cliFlags = {
description:
'If backend is vercel, the vercel project to authenticate against',
required: false,
- default: '',
dependsOn: ['backend'],
env: 'WORKFLOW_VERCEL_PROJECT',
helpGroup: 'Target',
@@ -65,7 +62,6 @@ export const cliFlags = {
'If backend is vercel, the vercel team to authenticate against',
required: false,
dependsOn: ['backend'],
- default: '',
env: 'WORKFLOW_VERCEL_TEAM',
helpGroup: 'Target',
helpLabel: '--team',
@@ -75,7 +71,6 @@ export const cliFlags = {
description: 'If backend is vercel, the vercel environment to use',
required: false,
options: ['production', 'preview'],
- default: 'production',
char: 'e',
dependsOn: ['backend'],
env: 'WORKFLOW_VERCEL_ENV',
diff --git a/packages/cli/src/lib/inspect/setup.test.ts b/packages/cli/src/lib/inspect/setup.test.ts
new file mode 100644
index 0000000000..01c0489134
--- /dev/null
+++ b/packages/cli/src/lib/inspect/setup.test.ts
@@ -0,0 +1,74 @@
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { setRuntimeWorkflowConfig } from '@workflow/config/runtime';
+import { closeWorld } from '@workflow/core/runtime';
+import type { World } from '@workflow/world';
+import { resolveQueueNamespace } from '@workflow/world/queue.js';
+import { afterEach, expect, it, vi } from 'vitest';
+import { setupCliWorld } from './setup.js';
+
+vi.mock('../update-check.js', () => ({
+ checkForUpdateCached: () => ({ needsUpdate: false }),
+}));
+
+const project = mkdtempSync(join(tmpdir(), 'workflow-cli-world-'));
+const originalCwd = process.env.WORKFLOW_OBSERVABILITY_CWD;
+const originalTarget = process.env.WORKFLOW_TARGET_WORLD;
+
+afterEach(async () => {
+ await closeWorld();
+ setRuntimeWorkflowConfig(undefined);
+ delete (globalThis as { __workflowCliWorldStarted?: boolean })
+ .__workflowCliWorldStarted;
+ if (originalCwd === undefined) {
+ delete process.env.WORKFLOW_OBSERVABILITY_CWD;
+ } else {
+ process.env.WORKFLOW_OBSERVABILITY_CWD = originalCwd;
+ }
+ if (originalTarget === undefined) {
+ delete process.env.WORKFLOW_TARGET_WORLD;
+ } else {
+ process.env.WORKFLOW_TARGET_WORLD = originalTarget;
+ }
+ rmSync(project, { recursive: true, force: true });
+});
+
+it('uses the configured World instead of the implicit local default', async () => {
+ writeFileSync(
+ join(project, 'workflow.config.ts'),
+ `export default {
+ world: './workflow.world.mjs',
+ queue: { namespace: 'configured' }
+ };`
+ );
+ writeFileSync(
+ join(project, 'workflow.world.mjs'),
+ `export default () => ({
+ source: 'configured',
+ start() { globalThis.__workflowCliWorldStarted = true; }
+ });`
+ );
+ process.env.WORKFLOW_OBSERVABILITY_CWD = project;
+ delete process.env.WORKFLOW_TARGET_WORLD;
+
+ const world = await setupCliWorld(
+ {
+ json: true,
+ verbose: false,
+ env: 'production',
+ authToken: '',
+ project: '',
+ team: '',
+ },
+ 'test'
+ );
+
+ expect((world as World & { source: string }).source).toBe('configured');
+ expect(
+ (globalThis as { __workflowCliWorldStarted?: boolean })
+ .__workflowCliWorldStarted
+ ).toBe(true);
+ expect(process.env.WORKFLOW_TARGET_WORLD).toBeUndefined();
+ expect(resolveQueueNamespace()).toBe('configured');
+});
diff --git a/packages/cli/src/lib/inspect/setup.ts b/packages/cli/src/lib/inspect/setup.ts
index 36922a3c51..fc35562136 100644
--- a/packages/cli/src/lib/inspect/setup.ts
+++ b/packages/cli/src/lib/inspect/setup.ts
@@ -1,10 +1,12 @@
-import { createWorld, setWorld } from '@workflow/core/runtime';
+import { createRuntimeWorkflowConfig } from '@workflow/config/load';
+import { setRuntimeWorkflowConfig } from '@workflow/config/runtime';
+import { getWorld, setWorld } from '@workflow/core/runtime';
import { isVercelWorldTarget } from '@workflow/utils';
-import type { World } from '@workflow/world';
import { createVercelWorld } from '@workflow/world-vercel';
import chalk from 'chalk';
import terminalLink from 'terminal-link';
import { logger, setJsonMode, setVerboseMode } from '../config/log.js';
+import { loadProjectWorkflowConfig } from '../config/workflow-config.js';
import { checkForUpdateCached } from '../update-check.js';
import {
inferLocalWorldEnvVars,
@@ -13,20 +15,17 @@ import {
writeEnvVars,
} from './env.js';
-/**
- * Setup CLI world configuration.
- * If throwOnConfigError is false, will return null world with the error message
- * instead of throwing, allowing the web UI to open for configuration.
- */
+/** Set up the CLI World, allowing the web UI to ignore missing local data. */
export const setupCliWorld = async (
flags: {
json: boolean;
verbose: boolean;
- backend: string;
- env: string;
- authToken: string;
- project: string;
- team: string;
+ backend?: string;
+ env?: string;
+ authToken?: string;
+ project?: string;
+ team?: string;
+ port?: number;
},
version: string,
ignoreLocalWorldConfigError = false
@@ -34,10 +33,22 @@ export const setupCliWorld = async (
setJsonMode(Boolean(flags.json));
setVerboseMode(Boolean(flags.verbose));
+ const loadedConfig = await loadProjectWorkflowConfig();
+ setRuntimeWorkflowConfig(createRuntimeWorkflowConfig(loadedConfig));
+
+ const backend =
+ flags.backend ??
+ process.env.WORKFLOW_TARGET_WORLD ??
+ (loadedConfig.config.world
+ ? undefined
+ : process.env.VERCEL_DEPLOYMENT_ID
+ ? 'vercel'
+ : 'local');
+
// Check for updates
const updateCheck = await checkForUpdateCached(version);
- const withAnsiLinks = flags.json ? false : true;
+ const withAnsiLinks = !flags.json;
const docsUrl = withAnsiLinks
? terminalLink('https://workflow-sdk.dev/', 'https://workflow-sdk.dev/')
: 'https://workflow-sdk.dev/';
@@ -73,27 +84,33 @@ export const setupCliWorld = async (
logger.showBox('green', ...boxLines);
- logger.debug('Inferring env vars, backend:', flags.backend);
+ logger.debug(
+ 'Inferring env vars, backend:',
+ backend ?? loadedConfig.config.world
+ );
writeEnvVars({
DEBUG: flags.verbose ? '1' : '',
- WORKFLOW_TARGET_WORLD: flags.backend,
});
+ if (backend) writeEnvVars({ WORKFLOW_TARGET_WORLD: backend });
let vercelEnvVars: VercelEnvVars | undefined;
- if (isVercelWorldTarget(flags.backend)) {
+ if (backend && isVercelWorldTarget(backend)) {
// Seed the initial flags into process.env so inferVercelEnvVars() can
// read them via getEnvVars() as starting values before inference.
writeEnvVars({
- WORKFLOW_VERCEL_ENV: flags.env,
+ WORKFLOW_VERCEL_ENV:
+ flags.env ?? process.env.WORKFLOW_VERCEL_ENV ?? 'production',
WORKFLOW_VERCEL_AUTH_TOKEN: flags.authToken,
WORKFLOW_VERCEL_PROJECT: flags.project,
WORKFLOW_VERCEL_TEAM: flags.team,
});
vercelEnvVars = await inferVercelEnvVars();
- } else if (
- flags.backend === 'local' ||
- flags.backend === '@workflow/world-local'
- ) {
+ } else if (backend === 'local' || backend === '@workflow/world-local') {
+ if (flags.port) {
+ writeEnvVars({
+ WORKFLOW_LOCAL_BASE_URL: `http://localhost:${flags.port}`,
+ });
+ }
try {
await inferLocalWorldEnvVars();
} catch (error) {
@@ -114,11 +131,10 @@ export const setupCliWorld = async (
logger.debug('Initializing world');
- let world: World;
if (vercelEnvVars) {
// Build the Vercel world directly from the inferred config, rather than
// relying on createWorld() reading process.env.
- world = createVercelWorld({
+ const world = createVercelWorld({
token: vercelEnvVars.token,
projectConfig: {
environment: vercelEnvVars.environment,
@@ -127,11 +143,9 @@ export const setupCliWorld = async (
teamId: vercelEnvVars.teamId,
},
});
- } else {
- world = await createWorld();
+ setWorld(world);
+ return world;
}
- // Store in the global cache so BaseCommand.finally() can find and close it.
- setWorld(world);
- return world;
+ return getWorld();
};
diff --git a/packages/config/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..0aa8d69ad8
--- /dev/null
+++ b/packages/config/src/load.test.ts
@@ -0,0 +1,273 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { afterEach, describe, expect, it } from 'vitest';
+import { loadWorkflowConfig } from './load.js';
+import {
+ getRuntimeWorkflowConfig,
+ setRuntimeWorkflowConfig,
+} from './runtime.js';
+import { WorkflowConfigSchema } from './schema.js';
+
+const tempDirs: string[] = [];
+
+function createProject(files: Record): string {
+ const project = mkdtempSync(join(tmpdir(), 'workflow-config-'));
+ tempDirs.push(project);
+
+ for (const [file, contents] of Object.entries(files)) {
+ const path = join(project, file);
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, contents, 'utf8');
+ }
+
+ return project;
+}
+
+afterEach(() => {
+ setRuntimeWorkflowConfig(undefined);
+ delete process.env.WORKFLOW_QUEUE_NAMESPACE;
+ delete (globalThis as { __workflowWorldImports?: number })
+ .__workflowWorldImports;
+ for (const dir of tempDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+describe('loadWorkflowConfig', () => {
+ it('returns empty config when no config file exists', async () => {
+ const project = createProject({});
+
+ await expect(loadWorkflowConfig({ cwd: project })).resolves.toEqual({
+ path: undefined,
+ runtimePath: undefined,
+ config: {},
+ });
+ });
+
+ it('allows config without a World provider', async () => {
+ const project = createProject({
+ 'workflow.config.ts': `export default { queue: { namespace: 'app' } };`,
+ });
+ const loaded = await loadWorkflowConfig({ cwd: project });
+ await import(pathToFileURL(loaded.runtimePath as string).href);
+
+ expect(getRuntimeWorkflowConfig()).toEqual({
+ world: undefined,
+ queue: { namespace: 'app' },
+ });
+ });
+
+ it('preserves a build-time queue namespace without a config file', async () => {
+ process.env.WORKFLOW_QUEUE_NAMESPACE = 'environment';
+ const project = createProject({});
+
+ const loaded = await loadWorkflowConfig({ cwd: project });
+ await import(pathToFileURL(loaded.runtimePath as string).href);
+
+ expect(loaded.path).toBeUndefined();
+ expect(loaded.config.queue).toEqual({ namespace: 'environment' });
+ expect(getRuntimeWorkflowConfig()?.queue).toEqual({
+ namespace: 'environment',
+ });
+ });
+
+ it('loads the nearest TypeScript config without merging parents', async () => {
+ const project = createProject({
+ 'workflow.config.ts': `export default { build: { dirs: ['parent'] } };`,
+ 'apps/web/workflow.config.ts': `export default {
+ build: { dirs: ['app'], sourcemap: false },
+ integration: { type: 'next' }
+ };`,
+ });
+ const app = join(project, 'apps', 'web');
+
+ const loaded = await loadWorkflowConfig({
+ cwd: app,
+ integration: 'next',
+ });
+
+ expect(loaded.path).toBe(join(app, 'workflow.config.ts'));
+ expect(loaded.runtimePath).toBeUndefined();
+ expect(loaded.config).toEqual({
+ build: { dirs: ['app'], sourcemap: false },
+ integration: { type: 'next' },
+ });
+ });
+
+ it('generates a runtime binding without loading the World module', async () => {
+ const project = createProject({
+ 'workflow.world.ts': `throw new Error('must stay lazy');`,
+ 'workflow.config.ts': `export default {
+ world: './workflow.world.ts',
+ build: { dirs: ['jobs'] },
+ queue: { namespace: 'app' }
+ };`,
+ });
+
+ const loaded = await loadWorkflowConfig({ cwd: project });
+ const runtimeSource = readFileSync(loaded.runtimePath as string, 'utf8');
+
+ expect(loaded.config.world).toBe('./workflow.world.ts');
+ expect(runtimeSource).toContain('workflow.world.ts');
+ expect(runtimeSource).toContain('queue: {"namespace":"app"}');
+ expect(runtimeSource).not.toContain("dirs: ['jobs']");
+ });
+
+ it('loads the World module only when its provider runs', async () => {
+ const globals = globalThis as typeof globalThis & {
+ __workflowWorldImports?: number;
+ };
+ const project = createProject({
+ 'workflow.world.mjs': `
+globalThis.__workflowWorldImports = (globalThis.__workflowWorldImports ?? 0) + 1;
+export default () => ({});
+`,
+ 'workflow.config.ts': `export default { world: './workflow.world.mjs' };`,
+ });
+ const loaded = await loadWorkflowConfig({ cwd: project });
+
+ await import(pathToFileURL(loaded.runtimePath as string).href);
+ expect(globals.__workflowWorldImports).toBeUndefined();
+
+ await getRuntimeWorkflowConfig()?.world?.();
+ expect(globals.__workflowWorldImports).toBe(1);
+ });
+
+ it('validates World package specifiers', async () => {
+ const project = createProject({
+ 'node_modules/community-world/package.json': JSON.stringify({
+ name: 'community-world',
+ type: 'module',
+ exports: { import: './index.js' },
+ }),
+ 'node_modules/community-world/index.js': `export default () => ({});`,
+ 'workflow.config.ts': `export default { world: 'community-world' };`,
+ });
+
+ const loaded = await loadWorkflowConfig({ cwd: project });
+
+ expect(readFileSync(loaded.runtimePath as string, 'utf8')).toContain(
+ 'import("community-world")'
+ );
+ });
+
+ it('rejects missing World modules', async () => {
+ const project = createProject({
+ 'workflow.config.ts': `export default { world: './missing.ts' };`,
+ });
+
+ await expect(loadWorkflowConfig({ cwd: project })).rejects.toThrow(
+ 'World module not found: ./missing.ts'
+ );
+ });
+
+ it('rejects absolute World paths', async () => {
+ const project = createProject({
+ 'workflow.world.ts': `export default () => ({});`,
+ });
+ writeFileSync(
+ join(project, 'workflow.config.ts'),
+ `export default { world: ${JSON.stringify(join(project, 'workflow.world.ts'))} };`
+ );
+
+ await expect(loadWorkflowConfig({ cwd: project })).rejects.toThrow(
+ 'World module must be a relative path or package specifier'
+ );
+ });
+
+ it.each([
+ 'file:///tmp/world.mjs',
+ 'data:text/javascript,export default () => ({})',
+ 'node:fs',
+ 'C:\\world.mjs',
+ ])('rejects non-package World specifier %s', async (world) => {
+ const project = createProject({
+ 'workflow.config.ts': `export default { world: ${JSON.stringify(world)} };`,
+ });
+
+ await expect(loadWorkflowConfig({ cwd: project })).rejects.toThrow(
+ 'World module must be a relative path or package specifier'
+ );
+ });
+
+ it('rejects multiple config files in one directory', async () => {
+ const project = createProject({
+ 'workflow.config.ts': `export default { build: { dirs: ['typescript'] } };`,
+ 'workflow.config.mjs': `export default { build: { dirs: ['javascript'] } };`,
+ });
+
+ await expect(loadWorkflowConfig({ cwd: project })).rejects.toThrow(
+ 'Multiple Workflow config files found'
+ );
+ });
+
+ it('rejects unsupported filenames instead of silently using a parent', async () => {
+ const project = createProject({
+ 'app/workflow.config.json': JSON.stringify({
+ build: { dirs: ['workflows'] },
+ }),
+ });
+ const app = join(project, 'app');
+
+ await expect(loadWorkflowConfig({ cwd: app })).rejects.toThrow(
+ 'Unsupported Workflow config file'
+ );
+ });
+
+ it('rejects integration config for another platform', async () => {
+ const project = createProject({
+ 'workflow.config.ts': `export default { integration: { type: 'nitro' } };`,
+ });
+
+ await expect(
+ loadWorkflowConfig({
+ cwd: project,
+ integration: 'next',
+ })
+ ).rejects.toThrow('configures "nitro" but was loaded by "next"');
+ });
+
+ it('rejects top-level config functions and unknown keys', async () => {
+ const project = createProject({
+ 'workflow.config.ts': `export default () => ({ build: { dirs: ['workflows'] } });`,
+ });
+
+ await expect(loadWorkflowConfig({ cwd: project })).rejects.toThrow(
+ 'must default-export a static object'
+ );
+
+ const promiseProject = createProject({
+ 'workflow.config.ts': `export default Promise.resolve({ build: { dirs: ['workflows'] } });`,
+ });
+ await expect(loadWorkflowConfig({ cwd: promiseProject })).rejects.toThrow(
+ 'must default-export a static object'
+ );
+
+ expect(() => WorkflowConfigSchema.parse({ unknown: true })).toThrow(
+ 'Unrecognized key'
+ );
+ });
+
+ it('rejects an empty queue section', () => {
+ expect(() => WorkflowConfigSchema.parse({ queue: {} })).toThrow();
+ });
+
+ it('rejects mixed integration settings', () => {
+ expect(() =>
+ WorkflowConfigSchema.parse({
+ integration: {
+ type: 'next',
+ typescriptPlugin: true,
+ },
+ })
+ ).toThrow();
+ });
+});
diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts
new file mode 100644
index 0000000000..e0174a676f
--- /dev/null
+++ b/packages/config/src/load.ts
@@ -0,0 +1,191 @@
+import assert from 'node:assert/strict';
+import {
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ statSync,
+ writeFileSync,
+} from 'node:fs';
+import {
+ basename,
+ dirname,
+ extname,
+ isAbsolute,
+ join,
+ relative,
+ resolve,
+ win32,
+} from 'node:path';
+import type { WorldProvider } from '@workflow/world';
+import { findUp } from 'find-up';
+import { createJiti } from 'jiti';
+import type { RuntimeWorkflowConfig } from './runtime.js';
+import {
+ type WorkflowConfig,
+ WorkflowConfigSchema,
+ type WorkflowIntegrationType,
+} from './schema.js';
+
+const WORKFLOW_CONFIG_FILES = [
+ 'workflow.config.ts',
+ 'workflow.config.mjs',
+ 'workflow.config.js',
+] as const;
+
+export type LoadWorkflowConfigOptions = {
+ cwd: string;
+ configFile?: string;
+ integration?: WorkflowIntegrationType;
+};
+
+export type LoadedWorkflowConfig = {
+ path: string | undefined;
+ runtimePath: string | undefined;
+ config: WorkflowConfig;
+};
+
+async function discoverWorkflowConfig({
+ cwd,
+ configFile,
+}: Pick): Promise<
+ string | undefined
+> {
+ if (configFile) {
+ const path = isAbsolute(configFile) ? configFile : resolve(cwd, configFile);
+ assert(
+ ['.ts', '.mjs', '.js'].includes(extname(path)),
+ `Unsupported Workflow config extension "${extname(path)}".`
+ );
+ assert(
+ existsSync(path) && statSync(path).isFile(),
+ `Workflow config file not found: ${path}`
+ );
+ return path;
+ }
+
+ return findUp(
+ (directory) => {
+ const configs = readdirSync(directory).filter((file) =>
+ file.startsWith('workflow.config.')
+ );
+ assert(
+ configs.length <= 1,
+ `Multiple Workflow config files found in ${directory}: ${configs.join(', ')}`
+ );
+
+ const config = configs[0];
+ if (!config) return;
+
+ assert(
+ WORKFLOW_CONFIG_FILES.some((file) => file === config),
+ `Unsupported Workflow config file "${config}".`
+ );
+ return join(directory, config);
+ },
+ { cwd }
+ );
+}
+
+async function readWorkflowConfig(
+ path: string,
+ integration: WorkflowIntegrationType | undefined
+): Promise {
+ const configModule = await createJiti(import.meta.url, {
+ interopDefault: false,
+ }).import<{ default: unknown }>(path);
+ const rawConfig = configModule.default;
+
+ assert(
+ rawConfig !== null &&
+ typeof rawConfig === 'object' &&
+ Object.getPrototypeOf(rawConfig) === Object.prototype,
+ `${basename(path)} must default-export a static object.`
+ );
+
+ const config = WorkflowConfigSchema.parse(rawConfig);
+ if (!integration || !config.integration) return config;
+
+ assert(
+ config.integration.type === integration,
+ `${basename(path)} configures "${config.integration.type}" but was loaded by "${integration}".`
+ );
+ return config;
+}
+
+export async function loadWorkflowConfig(
+ options: LoadWorkflowConfigOptions
+): Promise {
+ const path = await discoverWorkflowConfig(options);
+ let config: WorkflowConfig = path
+ ? await readWorkflowConfig(path, options.integration)
+ : {};
+
+ if (process.env.WORKFLOW_QUEUE_NAMESPACE !== undefined) {
+ config = WorkflowConfigSchema.parse({
+ ...config,
+ queue: { namespace: process.env.WORKFLOW_QUEUE_NAMESPACE },
+ });
+ }
+
+ if (!config.world && !config.queue) {
+ return { path, runtimePath: undefined, config };
+ }
+
+ const runtimeDir = join(
+ path ? dirname(path) : options.cwd,
+ 'node_modules',
+ '.cache',
+ 'workflow'
+ );
+ let world = config.world;
+ if (world) {
+ assert(path);
+ assert(
+ !isAbsolute(world) &&
+ !win32.isAbsolute(world) &&
+ !/^[a-z][a-z\d+.-]*:/i.test(world),
+ `World module must be a relative path or package specifier: ${world}`
+ );
+ if (world.startsWith('.')) {
+ const worldPath = resolve(dirname(path), world);
+ assert(
+ existsSync(worldPath) && statSync(worldPath).isFile(),
+ `World module not found: ${world}`
+ );
+ world = relative(runtimeDir, worldPath).replaceAll('\\', '/');
+ if (!world.startsWith('.')) world = `./${world}`;
+ } else {
+ createJiti(path).esmResolve(world);
+ }
+ }
+
+ const runtimePath = join(runtimeDir, 'runtime-config.mjs');
+ const worldFactory = world
+ ? `async () => { const provider = (await import(${JSON.stringify(world)})).default; return provider(); }`
+ : 'undefined';
+ mkdirSync(runtimeDir, { recursive: true });
+ writeFileSync(
+ runtimePath,
+ `const world = ${worldFactory};\nglobalThis[Symbol.for('@workflow/config/runtime')] = { world, queue: ${JSON.stringify(config.queue)} };\n`
+ );
+
+ return { path, runtimePath, config };
+}
+
+export function createRuntimeWorkflowConfig({
+ path,
+ config,
+}: LoadedWorkflowConfig): RuntimeWorkflowConfig {
+ if (!config.world) return { queue: config.queue };
+
+ assert(path);
+ const world = config.world;
+ const jiti = createJiti(path, { interopDefault: false });
+ return {
+ queue: config.queue,
+ world: async () => {
+ const module = await jiti.import<{ default: WorldProvider }>(world);
+ return module.default();
+ },
+ };
+}
diff --git a/packages/config/src/runtime-binding.ts b/packages/config/src/runtime-binding.ts
new file mode 100644
index 0000000000..cb0ff5c3b5
--- /dev/null
+++ b/packages/config/src/runtime-binding.ts
@@ -0,0 +1 @@
+export {};
diff --git a/packages/config/src/runtime.ts b/packages/config/src/runtime.ts
new file mode 100644
index 0000000000..77aebbe67c
--- /dev/null
+++ b/packages/config/src/runtime.ts
@@ -0,0 +1,24 @@
+import type { WorldProvider } from '@workflow/world';
+import { setWorkflowQueueNamespace } from '@workflow/world/queue.js';
+import type { WorkflowConfig } from './schema.js';
+
+export type RuntimeWorkflowConfig = Pick & {
+ world?: WorldProvider;
+};
+
+const RuntimeWorkflowConfigSymbol = Symbol.for('@workflow/config/runtime');
+
+const globals = globalThis as typeof globalThis & {
+ [RuntimeWorkflowConfigSymbol]?: RuntimeWorkflowConfig;
+};
+
+export function getRuntimeWorkflowConfig(): RuntimeWorkflowConfig | undefined {
+ return globals[RuntimeWorkflowConfigSymbol];
+}
+
+export function setRuntimeWorkflowConfig(
+ config: RuntimeWorkflowConfig | undefined
+): void {
+ globals[RuntimeWorkflowConfigSymbol] = config;
+ setWorkflowQueueNamespace(config?.queue?.namespace);
+}
diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts
new file mode 100644
index 0000000000..e75ed40622
--- /dev/null
+++ b/packages/config/src/schema.ts
@@ -0,0 +1,49 @@
+import { QueueNamespaceSchema } from '@workflow/world/queue.js';
+import { z } from 'zod/v4';
+
+const sourcemapSchema = z.union([
+ z.boolean(),
+ z.enum(['inline', 'linked', 'external', 'both']),
+]);
+export type SourcemapMode = z.infer;
+
+const integrationSchema = z.discriminatedUnion('type', [
+ z.strictObject({
+ type: z.literal('next'),
+ }),
+ z.strictObject({
+ type: z.literal('nitro'),
+ typescriptPlugin: z.boolean().optional(),
+ runtime: z.string().min(1).optional(),
+ }),
+]);
+
+export const WorkflowConfigSchema = z.strictObject({
+ world: z.string().min(1).optional(),
+ build: z
+ .strictObject({
+ dirs: z.array(z.string().min(1)).min(1).optional(),
+ projectRoot: z.string().min(1).optional(),
+ externalPackages: z.array(z.string().min(1)).optional(),
+ sourcemap: sourcemapSchema.optional(),
+ manifest: z
+ .strictObject({
+ public: z.boolean().optional(),
+ output: z.string().min(1).optional(),
+ })
+ .optional(),
+ })
+ .optional(),
+ queue: z
+ .strictObject({
+ namespace: QueueNamespaceSchema,
+ })
+ .optional(),
+ integration: integrationSchema.optional(),
+});
+
+export type WorkflowConfig = z.infer;
+export type WorkflowIntegrationType =
+ | NonNullable['type']
+ | 'astro'
+ | 'sveltekit';
diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json
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 5ddb9c7937..a70772b44a 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -95,6 +95,7 @@
"@standard-schema/spec": "1.0.0",
"@types/ms": "2.1.0",
"@vercel/functions": "catalog:",
+ "@workflow/config": "workspace:*",
"@workflow/errors": "workspace:*",
"@workflow/serde": "workspace:*",
"@workflow/utils": "workspace:*",
diff --git a/packages/core/src/runtime.test.ts b/packages/core/src/runtime.test.ts
index ea4dcdbd2d..4d9f40c9c2 100644
--- a/packages/core/src/runtime.test.ts
+++ b/packages/core/src/runtime.test.ts
@@ -8,6 +8,7 @@ import {
SPEC_VERSION_CURRENT,
type WorkflowRun,
} from '@workflow/world';
+import { setWorkflowQueueNamespace } from '@workflow/world/queue.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerStepFunction } from './private.js';
import { REPLAY_DIVERGENCE_MAX_RETRIES } from './runtime/constants.js';
@@ -125,6 +126,57 @@ async function runWorkflowHandlerWithEvents(
return createdEvents;
}
+describe('workflowEntrypoint World replacement', () => {
+ afterEach(() => {
+ setWorld(undefined);
+ setWorkflowQueueNamespace(undefined);
+ });
+
+ it('resolves the namespace when the handler binds to its World', async () => {
+ const createQueueHandler = vi.fn(() => async () => new Response());
+ setWorld({
+ specVersion: SPEC_VERSION_CURRENT,
+ createQueueHandler,
+ } as any);
+ const entrypoint = workflowEntrypoint('');
+
+ setWorkflowQueueNamespace('app');
+ await entrypoint(new Request('https://example.test'));
+
+ expect(createQueueHandler).toHaveBeenCalledWith(
+ '__app_wkf_workflow_',
+ expect.any(Function)
+ );
+ });
+
+ it('rebuilds its queue handler after the World changes', async () => {
+ const firstHandler = vi.fn(async () => new Response('first'));
+ const secondHandler = vi.fn(async () => new Response('second'));
+ const firstCreate = vi.fn(() => firstHandler);
+ const secondCreate = vi.fn(() => secondHandler);
+ const entrypoint = workflowEntrypoint('');
+
+ setWorld({
+ specVersion: SPEC_VERSION_CURRENT,
+ createQueueHandler: firstCreate,
+ } as any);
+ expect(
+ await (await entrypoint(new Request('https://example.test'))).text()
+ ).toBe('first');
+
+ setWorld(undefined);
+ setWorld({
+ specVersion: SPEC_VERSION_CURRENT,
+ createQueueHandler: secondCreate,
+ } as any);
+ expect(
+ await (await entrypoint(new Request('https://example.test'))).text()
+ ).toBe('second');
+ expect(firstCreate).toHaveBeenCalledOnce();
+ expect(secondCreate).toHaveBeenCalledOnce();
+ });
+});
+
describe('workflowEntrypoint replay guards', () => {
afterEach(() => {
setWorld(undefined);
diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts
index 49b2dd5281..4d4aa59d3b 100644
--- a/packages/core/src/runtime.ts
+++ b/packages/core/src/runtime.ts
@@ -49,11 +49,7 @@ import {
import { executeStep } from './runtime/step-executor.js';
import { handleSuspension } from './runtime/suspension-handler.js';
import { getWaitContinuationDispatch } from './runtime/wait-continuation.js';
-import {
- getWorld,
- getWorldHandlers,
- type WorldHandlers,
-} from './runtime/world.js';
+import { getWorld, type WorldHandlers } from './runtime/world.js';
import { dehydrateRunError } from './serialization.js';
import { remapErrorStack } from './source-map.js';
import {
@@ -118,10 +114,12 @@ export {
// prevents Turbopack from tracing step-handler.js → get-port.js
// filesystem operations into the flow route bundle.
export {
+ closeWorld,
createWorld,
getWorld,
getWorldHandlers,
setWorld,
+ usesConfiguredWorld,
} from './runtime/world.js';
function getWorkflowSetupErrorCode(err: unknown): RunErrorCode | null {
@@ -292,11 +290,10 @@ export function workflowEntrypoint(
const NO_INLINE_REPLAY_AFTER_MS =
Number(process.env.WORKFLOW_V2_TIMEOUT_MS) || 120_000;
- const namespace = resolveQueueNamespace(options?.namespace);
- const workflowPrefix = getQueueTopicPrefix('workflow', namespace);
-
- const handler = (worldHandlers: WorldHandlers) =>
- worldHandlers.createQueueHandler(
+ const handler = (worldHandlers: WorldHandlers) => {
+ const namespace = resolveQueueNamespace(options?.namespace);
+ const workflowPrefix = getQueueTopicPrefix('workflow', namespace);
+ return worldHandlers.createQueueHandler(
workflowPrefix,
async (message_, metadata) => {
// Check if this is a health check message
@@ -950,7 +947,6 @@ export function workflowEntrypoint(
const encryptionKey = await getEncryptionKey();
// Main replay loop
- // biome-ignore lint/correctness/noConstantCondition: intentional loop
while (true) {
loopIteration++;
@@ -2022,8 +2018,10 @@ export function workflowEntrypoint(
}); // End withTraceContext
}
);
+ };
let cachedHandler: ((req: Request) => Promise) | undefined;
+ let cachedWorld: World | undefined;
let invocationCount = 0;
const entrypointCreatedAt = Date.now();
const routeModuleBodyInitMs =
@@ -2033,7 +2031,8 @@ export function workflowEntrypoint(
return withHealthCheck(async (req) => {
invocationCount += 1;
- const handlerCached = cachedHandler !== undefined;
+ const world = await getWorld();
+ const handlerCached = cachedHandler !== undefined && cachedWorld === world;
const spanKind = await getSpanKind('SERVER');
return trace(
@@ -2055,14 +2054,15 @@ export function workflowEntrypoint(
},
},
async (span) => {
- if (!cachedHandler) {
+ if (!cachedHandler || cachedWorld !== world) {
cachedHandler = await trace('workflow.route.init', async () => {
const worldHandlers = await trace(
'workflow.route.get_world_handlers',
- async () => getWorldHandlers()
+ async () => world
);
return handler(worldHandlers);
});
+ cachedWorld = world;
}
const response = await cachedHandler(req);
diff --git a/packages/core/src/runtime/get-world-lazy.ts b/packages/core/src/runtime/get-world-lazy.ts
index bebb188c9d..e7616ceb73 100644
--- a/packages/core/src/runtime/get-world-lazy.ts
+++ b/packages/core/src/runtime/get-world-lazy.ts
@@ -9,14 +9,13 @@
*
* Resolution order, in priority:
*
- * 1. `globalThis[WorldCacheKey]` — populated by a successful prior
- * `getWorld()` call. This is the steady-state hot path.
- * 2. `globalThis[GetWorldFnKey]` — populated by the module-load side
+ * 1. `globalThis[GetWorldFnKey]` — populated by the module-load side
* effect at the bottom of `./world.ts`. Fires on every server bundle
* that reaches this file via `workflow/api` (which imports
* `./world-init.ts` for its side effect; see that file for the full
- * rationale). This is the cold-start path for routes that consume
- * `start` without any prior workflow run.
+ * rationale).
+ * 2. The global World cache or pending creation promise when world.ts is not
+ * present in the bundle.
* 3. Dynamic `import('./world.js')` — last-resort fallback for
* environments where neither (1) nor (2) is available (CJS test
* runners, scripts that import deeply into `@workflow/core` without
@@ -36,18 +35,17 @@ const GetWorldFnKey = Symbol.for('@workflow/world//getWorldFn');
export async function getWorldLazy(): Promise {
const g = globalThis as any;
+ const getWorldFn = g[GetWorldFnKey] as (() => Promise) | undefined;
+ if (getWorldFn) return getWorldFn();
if (g[WorldCacheKey]) return g[WorldCacheKey];
if (g[WorldCachePromiseKey]) {
- g[WorldCacheKey] = await g[WorldCachePromiseKey];
- return g[WorldCacheKey];
+ const pendingWorld = g[WorldCachePromiseKey];
+ const world = await pendingWorld;
+ if (g[WorldCachePromiseKey] === pendingWorld) {
+ g[WorldCacheKey] = world;
+ }
+ return world;
}
- // If world.ts is statically present in this bundle, it has registered
- // getWorld on globalThis at module load. Prefer that over the dynamic
- // import fallback, which doesn't survive Next.js inlining get-world-lazy
- // into a route bundle (the relative './world.js' resolves against the
- // bundled location, where no sibling world.js exists).
- const getWorldFn = g[GetWorldFnKey] as (() => Promise) | undefined;
- if (getWorldFn) return getWorldFn();
// Last resort: dynamic import for environments where world.ts wasn't
// bundled but is reachable as a sibling module on disk. The specifier is
// built at runtime so esbuild can't trace it into the step bundle.
diff --git a/packages/core/src/runtime/step-handler.test.ts b/packages/core/src/runtime/step-handler.test.ts
index dc5a4bd670..47bf568a53 100644
--- a/packages/core/src/runtime/step-handler.test.ts
+++ b/packages/core/src/runtime/step-handler.test.ts
@@ -1,9 +1,11 @@
+import assert from 'node:assert/strict';
import {
EntityConflictError,
FatalError,
ThrottleError,
WorkflowWorldError,
} from '@workflow/errors';
+import { setWorkflowQueueNamespace } from '@workflow/world/queue.js';
import {
afterEach,
beforeAll,
@@ -17,6 +19,7 @@ import {
// Use vi.hoisted so these are available in mock factories
const {
capturedHandlerRef,
+ capturedPrefixRef,
mockEventsCreate,
mockQueue,
mockRuntimeLogger,
@@ -31,6 +34,7 @@ const {
capturedHandlerRef: {
current: null as null | ((...args: unknown[]) => Promise),
},
+ capturedPrefixRef: { current: '' },
mockEventsCreate: vi.fn(),
mockQueue: vi.fn().mockResolvedValue({ messageId: 'msg_test' }),
mockRuntimeLogger: (() => {
@@ -68,21 +72,19 @@ vi.mock('@vercel/functions', () => ({
// Mock the world module - createQueueHandler captures the handler
vi.mock('./world.js', () => ({
getWorld: vi.fn(async () => ({
- events: { create: mockEventsCreate },
- queue: mockQueue,
- getEncryptionKeyForRun: vi.fn().mockResolvedValue(undefined),
- })),
- getWorldHandlers: vi.fn(async () => ({
createQueueHandler: vi.fn(
(
- _prefix: string,
+ prefix: string,
handler: (...args: unknown[]) => Promise
): ((req: Request) => Promise) => {
+ capturedPrefixRef.current = prefix;
capturedHandlerRef.current = handler;
- // Return a mock request handler
return vi.fn() as unknown as (req: Request) => Promise;
}
),
+ events: { create: mockEventsCreate },
+ queue: mockQueue,
+ getEncryptionKeyForRun: vi.fn().mockResolvedValue(undefined),
})),
}));
@@ -192,8 +194,7 @@ import {
import { MAX_QUEUE_DELIVERIES } from './constants.js';
import { executeStep } from './step-executor.js';
// Import the module AFTER all mocks are set up
-// Since getWorldHandlers is now async, we need to call stepEntrypoint
-// to trigger createQueueHandler and populate capturedHandlerRef
+// Call stepEntrypoint to trigger createQueueHandler and populate capturedHandlerRef.
import { stepEntrypoint } from './step-handler.js';
import { getWorld } from './world.js';
@@ -233,9 +234,20 @@ function createMessage(overrides: Record = {}) {
};
}
+describe('step-handler namespace', () => {
+ afterEach(() => setWorkflowQueueNamespace(undefined));
+
+ it('resolves the namespace when the handler binds to its World', async () => {
+ setWorkflowQueueNamespace('app');
+ await stepEntrypoint(new Request('http://localhost'));
+
+ expect(capturedPrefixRef.current).toBe('__app_wkf_step_');
+ });
+});
+
describe('step-handler 409 handling', () => {
// Trigger the lazy handler initialization by calling stepEntrypoint once.
- // This invokes getWorldHandlers() which calls createQueueHandler and captures the handler.
+ // This gets the World and captures the queue handler.
beforeAll(async () => {
await stepEntrypoint(new Request('http://localhost'));
});
@@ -283,7 +295,6 @@ describe('step-handler 409 handling', () => {
describe('step_completed 409', () => {
it('should warn and return when step_completed gets a 409', async () => {
// step_started succeeds, step function succeeds, step_completed returns 409
- let callCount = 0;
mockEventsCreate.mockImplementation(
(_runId: string, event: { eventType: string }) => {
if (event.eventType === 'step_started') {
@@ -299,7 +310,6 @@ describe('step-handler 409 handling', () => {
});
}
if (event.eventType === 'step_completed') {
- callCount++;
return Promise.reject(
new EntityConflictError(
'Cannot complete step because it is already completed'
@@ -536,8 +546,8 @@ describe('step-handler 409 handling', () => {
([, event]: [string, { eventType: string }]) =>
event.eventType === 'step_started'
);
- expect(startedCall).toBeDefined();
- expect(startedCall![2]).toEqual(
+ assert(startedCall);
+ expect(startedCall[2]).toEqual(
expect.objectContaining({ requestId: 'iad1::req-abc' })
);
});
@@ -552,8 +562,8 @@ describe('step-handler 409 handling', () => {
([, event]: [string, { eventType: string }]) =>
event.eventType === 'step_completed'
);
- expect(completedCall).toBeDefined();
- expect(completedCall![2]).toEqual(
+ assert(completedCall);
+ expect(completedCall[2]).toEqual(
expect.objectContaining({ requestId: 'iad1::req-abc' })
);
});
@@ -565,8 +575,8 @@ describe('step-handler 409 handling', () => {
([, event]: [string, { eventType: string }]) =>
event.eventType === 'step_started'
);
- expect(startedCall).toBeDefined();
- expect(startedCall![2]).toEqual(
+ assert(startedCall);
+ expect(startedCall[2]).toEqual(
expect.objectContaining({ requestId: undefined })
);
});
@@ -647,7 +657,7 @@ describe('step-handler max deliveries', () => {
});
it('should not trigger max deliveries check when under limit', async () => {
- const result = await capturedHandler(createMessage(), {
+ await capturedHandler(createMessage(), {
...createMetadata('myStep'),
attempt: MAX_QUEUE_DELIVERIES,
});
@@ -1213,7 +1223,7 @@ describe('executeStep optimistic inline start', () => {
it('sends step_started carrying the input and completes (when enabled)', async () => {
mockEventsCreate
.mockReset()
- .mockImplementation((_runId: string, event: { eventType: string }) =>
+ .mockImplementation((_runId: string, _event: { eventType: string }) =>
Promise.resolve({ event: {} })
);
diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts
index 2a8ba2631f..fcab674d87 100644
--- a/packages/core/src/runtime/step-handler.ts
+++ b/packages/core/src/runtime/step-handler.ts
@@ -18,6 +18,7 @@ import {
SPEC_VERSION_SUPPORTS_COMPRESSION,
type Step,
StepInvokePayloadSchema,
+ type World,
} from '@workflow/world';
import { describeError } from '../describe-error.js';
import { runtimeLogger, stepLogger } from '../logger.js';
@@ -58,7 +59,7 @@ import {
withHealthCheck,
} from './helpers.js';
import { safeWaitUntil } from './wait-until.js';
-import { getWorld, getWorldHandlers, type WorldHandlers } from './world.js';
+import { getWorld, type WorldHandlers } from './world.js';
const DEFAULT_STEP_MAX_RETRIES = 3;
@@ -1153,18 +1154,19 @@ function createStepHandler(namespace?: string) {
});
}
-const stepHandler = createStepHandler();
-
/**
* A single route that handles any step execution request and routes to the
* appropriate step function. We may eventually want to create different bundles
* for each step, this is temporary.
*/
let cachedStepHandler: ((req: Request) => Promise) | undefined;
+let cachedWorld: World | undefined;
export const stepEntrypoint: (req: Request) => Promise =
/* @__PURE__ */ withHealthCheck(async (req) => {
- if (!cachedStepHandler) {
- cachedStepHandler = stepHandler(await getWorldHandlers());
+ const world = await getWorld();
+ if (!cachedStepHandler || cachedWorld !== world) {
+ cachedStepHandler = createStepHandler()(world);
+ cachedWorld = world;
}
return cachedStepHandler(req);
});
diff --git a/packages/core/src/runtime/world-config.test.ts b/packages/core/src/runtime/world-config.test.ts
new file mode 100644
index 0000000000..a335fdd190
--- /dev/null
+++ b/packages/core/src/runtime/world-config.test.ts
@@ -0,0 +1,229 @@
+import { setRuntimeWorkflowConfig } from '@workflow/config/runtime';
+import type { World } from '@workflow/world';
+import { resolveQueueNamespace } from '@workflow/world/queue.js';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { getWorldLazy } from './get-world-lazy.js';
+import {
+ closeWorld,
+ getWorld,
+ getWorldHandlers,
+ setWorld,
+ usesConfiguredWorld,
+} from './world.js';
+
+const targetWorld = process.env.WORKFLOW_TARGET_WORLD;
+
+afterEach(async () => {
+ await closeWorld();
+ setRuntimeWorkflowConfig(undefined);
+ if (targetWorld === undefined) {
+ delete process.env.WORKFLOW_TARGET_WORLD;
+ } else {
+ process.env.WORKFLOW_TARGET_WORLD = targetWorld;
+ }
+});
+
+describe('configured World', () => {
+ it('creates, starts, shares, and closes one lazy World', async () => {
+ delete process.env.WORKFLOW_TARGET_WORLD;
+ const start = vi.fn(async () => {});
+ const close = vi.fn(async () => {});
+ const world = {
+ createQueueHandler: vi.fn(),
+ specVersion: 4,
+ start,
+ close,
+ } as unknown as World;
+ const create = vi.fn(() => world);
+
+ setRuntimeWorkflowConfig({
+ world: create,
+ queue: { namespace: 'app' },
+ });
+
+ expect(resolveQueueNamespace()).toBe('app');
+ expect(create).not.toHaveBeenCalled();
+ const [resolved, handlers] = await Promise.all([
+ getWorld(),
+ getWorldHandlers(),
+ ]);
+
+ expect(resolved).toBe(world);
+ expect(handlers.specVersion).toBe(4);
+ expect(create).toHaveBeenCalledOnce();
+ expect(start).toHaveBeenCalledOnce();
+
+ await closeWorld();
+ expect(close).toHaveBeenCalledOnce();
+ });
+
+ it('closes a World whose startup fails before retrying', async () => {
+ delete process.env.WORKFLOW_TARGET_WORLD;
+ const firstClose = vi.fn(async () => {});
+ const first = {
+ start: vi.fn().mockRejectedValue(new Error('startup failed')),
+ close: firstClose,
+ } as unknown as World;
+ const second = {
+ start: vi.fn(async () => {}),
+ close: vi.fn(async () => {}),
+ } as unknown as World;
+ const create = vi
+ .fn()
+ .mockReturnValueOnce(first)
+ .mockReturnValueOnce(second);
+ setRuntimeWorkflowConfig({ world: create });
+
+ expect(usesConfiguredWorld()).toBe(true);
+ await expect(getWorld()).rejects.toThrow('startup failed');
+ expect(firstClose).toHaveBeenCalledOnce();
+ await expect(getWorld()).resolves.toBe(second);
+ expect(create).toHaveBeenCalledTimes(2);
+ });
+
+ it('requires failed startup cleanup before creating a replacement', async () => {
+ delete process.env.WORKFLOW_TARGET_WORLD;
+ const firstClose = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('close failed'))
+ .mockResolvedValueOnce(undefined);
+ const first = {
+ start: vi.fn().mockRejectedValue(new Error('startup failed')),
+ close: firstClose,
+ } as unknown as World;
+ const second = {
+ start: vi.fn(async () => {}),
+ close: vi.fn(async () => {}),
+ } as unknown as World;
+ const create = vi
+ .fn()
+ .mockReturnValueOnce(first)
+ .mockReturnValueOnce(second);
+ setRuntimeWorkflowConfig({ world: create });
+
+ await expect(getWorld()).rejects.toThrow(
+ 'World startup and cleanup failed.'
+ );
+ expect(create).toHaveBeenCalledOnce();
+ await closeWorld();
+ expect(firstClose).toHaveBeenCalledTimes(2);
+ await expect(getWorld()).resolves.toBe(second);
+ });
+
+ it('rejects a configured provider that returns no World', async () => {
+ delete process.env.WORKFLOW_TARGET_WORLD;
+ setRuntimeWorkflowConfig({ world: () => undefined as never });
+
+ await expect(getWorld()).rejects.toThrow(
+ 'Configured World provider must return a World.'
+ );
+ });
+
+ it('requires managed Worlds to be closed before reset', async () => {
+ delete process.env.WORKFLOW_TARGET_WORLD;
+ const close = vi.fn(async () => {});
+ setRuntimeWorkflowConfig({
+ world: () =>
+ ({
+ start: vi.fn(async () => {}),
+ close,
+ }) as unknown as World,
+ });
+
+ await getWorld();
+ expect(() => setWorld(undefined)).toThrow(
+ 'Call await closeWorld() before replacing a managed World.'
+ );
+ await closeWorld();
+ expect(close).toHaveBeenCalledOnce();
+ expect(() => setWorld(undefined)).not.toThrow();
+ });
+
+ it('does not cache a World closed during startup', async () => {
+ delete process.env.WORKFLOW_TARGET_WORLD;
+ let finishStart!: () => void;
+ const starting = new Promise((resolve) => {
+ finishStart = resolve;
+ });
+ const firstClose = vi.fn(async () => {});
+ const first = {
+ start: vi.fn(() => starting),
+ close: firstClose,
+ } as unknown as World;
+ const second = {
+ start: vi.fn(async () => {}),
+ close: vi.fn(async () => {}),
+ } as unknown as World;
+ const create = vi
+ .fn()
+ .mockReturnValueOnce(first)
+ .mockReturnValueOnce(second);
+ setRuntimeWorkflowConfig({ world: create });
+
+ const pendingWorld = getWorld();
+ const pendingLazyWorld = getWorldLazy();
+ const closingWorld = closeWorld();
+ finishStart();
+
+ await expect(pendingWorld).resolves.toBe(first);
+ await expect(pendingLazyWorld).resolves.toBe(first);
+ await closingWorld;
+ expect(firstClose).toHaveBeenCalledOnce();
+ await expect(getWorld()).resolves.toBe(second);
+ expect(create).toHaveBeenCalledTimes(2);
+ });
+
+ it('waits for cleanup before starting a replacement World', async () => {
+ delete process.env.WORKFLOW_TARGET_WORLD;
+ let finishClose!: () => void;
+ const closing = new Promise((resolve) => {
+ finishClose = resolve;
+ });
+ const first = {
+ start: vi.fn(async () => {}),
+ close: vi.fn(() => closing),
+ } as unknown as World;
+ const second = {
+ start: vi.fn(async () => {}),
+ close: vi.fn(async () => {}),
+ } as unknown as World;
+ const create = vi
+ .fn()
+ .mockReturnValueOnce(first)
+ .mockReturnValueOnce(second);
+ setRuntimeWorkflowConfig({ world: create });
+
+ await getWorld();
+ const close = closeWorld();
+ const replacement = getWorld();
+ const replacementAfterConcurrentClose = closeWorld().then(() => getWorld());
+ expect(() => setWorld(second)).toThrow(
+ 'Cannot replace a World while it is closing.'
+ );
+ expect(create).toHaveBeenCalledOnce();
+
+ finishClose();
+ await close;
+ await expect(replacement).resolves.toBe(second);
+ await expect(replacementAfterConcurrentClose).resolves.toBe(second);
+ expect(create).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps a World cached when cleanup fails', async () => {
+ delete process.env.WORKFLOW_TARGET_WORLD;
+ const world = {
+ start: vi.fn(async () => {}),
+ close: vi
+ .fn()
+ .mockRejectedValueOnce(new Error('close failed'))
+ .mockResolvedValueOnce(undefined),
+ } as unknown as World;
+ const create = vi.fn(() => world);
+ setRuntimeWorkflowConfig({ world: create });
+
+ await getWorld();
+ await expect(closeWorld()).rejects.toThrow('close failed');
+ await expect(getWorld()).resolves.toBe(world);
+ expect(create).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts
index fcece0c860..655b08110d 100644
--- a/packages/core/src/runtime/world.ts
+++ b/packages/core/src/runtime/world.ts
@@ -1,10 +1,14 @@
+import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
+import { getRuntimeWorkflowConfig } from '@workflow/config/runtime';
+import '@workflow/config/runtime-binding';
import {
isVercelWorldTarget,
resolveWorkflowTargetWorld,
} from '@workflow/utils';
import type { World } from '@workflow/world';
+import { setWorkflowQueueNamespace } from '@workflow/world/queue.js';
import { createLocalWorld } from '@workflow/world-local';
import { createVercelWorld } from '@workflow/world-vercel';
@@ -14,26 +18,37 @@ 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 WorldLifecycleKey = Symbol.for('@workflow/world//lifecycle');
+
+type WorldLifecycle =
+ | { status: 'managed' }
+ | { status: 'cleanup'; world: World }
+ | { status: 'closing'; managed: boolean; promise: Promise };
const globalSymbols: typeof globalThis & {
[WorldCache]?: World;
- [StubbedWorldCache]?: World;
[WorldCachePromise]?: Promise;
- [StubbedWorldCachePromise]?: Promise;
+ [WorldLifecycleKey]?: WorldLifecycle;
} = globalThis;
+function getWorkflowConfig() {
+ return getRuntimeWorkflowConfig() ?? {};
+}
+
+export function usesConfiguredWorld(): boolean {
+ return !process.env.WORKFLOW_TARGET_WORLD && !!getWorkflowConfig().world;
+}
+
+setWorkflowQueueNamespace(getWorkflowConfig().queue?.namespace);
+
// Dynamic import for custom world modules. Uses a standard import()
// wrapped in a try/catch with require() fallback for CJS test runners.
// Note: the previous `new Function('specifier', 'return import(specifier)')`
@@ -53,7 +68,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 +91,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,64 +144,199 @@ 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.`
);
-};
+}
-export type WorldHandlers = Pick;
+type ResolvedWorld =
+ | { type: 'configured'; world: World }
+ | { type: 'legacy'; world: World };
-/**
- * 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.
- */
-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;
- });
+async function resolveWorld(): Promise {
+ const config = getWorkflowConfig();
+
+ if (process.env.WORKFLOW_TARGET_WORLD) {
+ return {
+ type: 'legacy',
+ world: await createLegacyWorld(),
+ };
}
- const _world = await globalSymbols[StubbedWorldCachePromise];
- globalSymbols[StubbedWorldCache] = _world;
+
+ if (config.world) {
+ const world = await config.world();
+ assert(world, 'Configured World provider must return a World.');
+ return {
+ type: 'configured',
+ world,
+ };
+ }
+
return {
- createQueueHandler: _world.createQueueHandler,
- specVersion: _world.specVersion,
+ type: 'legacy',
+ world: await createLegacyWorld(),
};
+}
+
+/**
+ * Create a new World instance from WORKFLOW_TARGET_WORLD when set, then
+ * workflow.config.ts, then the environment-aware default.
+ *
+ * This function does not call World.start(). Use getWorld() for the managed
+ * runtime singleton.
+ */
+export const createWorld = async (): Promise => {
+ return (await resolveWorld()).world;
};
+export type WorldHandlers = Pick;
+
+/**
+ * Queue handlers and regular runtime calls share one managed World. The World
+ * factory is never called by config loading or the build integrations; this
+ * path is reached only when host runtime code asks for a handler.
+ */
+export const getWorldHandlers = (): Promise => getWorld();
+
export const getWorld = async (): Promise => {
+ const lifecycle = globalSymbols[WorldLifecycleKey];
+ if (lifecycle?.status === 'closing') {
+ await lifecycle.promise;
+ } else if (lifecycle?.status === 'cleanup') {
+ await closeWorld();
+ }
+
if (globalSymbols[WorldCache]) {
return globalSymbols[WorldCache];
}
- // Store the promise immediately to prevent race conditions with concurrent calls.
- // Clear on rejection so subsequent calls can retry instead of caching the failure.
- if (!globalSymbols[WorldCachePromise]) {
- globalSymbols[WorldCachePromise] = createWorld().catch((err) => {
- globalSymbols[WorldCachePromise] = undefined;
- throw err;
+
+ let pendingWorld = globalSymbols[WorldCachePromise];
+ if (!pendingWorld) {
+ const managed =
+ !process.env.WORKFLOW_TARGET_WORLD && !!getWorkflowConfig().world;
+ globalSymbols[WorldLifecycleKey] = managed
+ ? { status: 'managed' }
+ : undefined;
+ pendingWorld = resolveWorld().then(async (resolved) => {
+ switch (resolved.type) {
+ case 'configured':
+ try {
+ await resolved.world.start?.();
+ } catch (startError) {
+ try {
+ await resolved.world.close?.();
+ } catch (closeError) {
+ globalSymbols[WorldLifecycleKey] = {
+ status: 'cleanup',
+ world: resolved.world,
+ };
+ throw new AggregateError(
+ [startError, closeError],
+ 'World startup and cleanup failed.'
+ );
+ }
+ throw startError;
+ }
+ return resolved.world;
+ case 'legacy':
+ return resolved.world;
+ default:
+ resolved satisfies never;
+ throw new Error('Unknown World resolution type');
+ }
});
+ globalSymbols[WorldCachePromise] = pendingWorld;
+ }
+
+ try {
+ const world = await pendingWorld;
+ if (globalSymbols[WorldCachePromise] === pendingWorld) {
+ globalSymbols[WorldCache] = world;
+ globalSymbols[WorldCachePromise] = undefined;
+ }
+ return world;
+ } catch (error) {
+ if (globalSymbols[WorldCachePromise] === pendingWorld) {
+ globalSymbols[WorldCachePromise] = undefined;
+ if (globalSymbols[WorldLifecycleKey]?.status === 'managed') {
+ globalSymbols[WorldLifecycleKey] = undefined;
+ }
+ }
+ throw error;
}
- globalSymbols[WorldCache] = await globalSymbols[WorldCachePromise];
- return globalSymbols[WorldCache];
};
-/**
- * Reset the cached world instance. This should be called when environment
- * variables change and you need to reinitialize the world with new config.
- */
+/** Override or clear an unmanaged cached World. */
export const setWorld = (world: World | undefined): void => {
+ const lifecycle = globalSymbols[WorldLifecycleKey];
+ assert(
+ lifecycle?.status !== 'closing',
+ 'Cannot replace a World while it is closing.'
+ );
+ assert(
+ lifecycle?.status !== 'managed' && lifecycle?.status !== 'cleanup',
+ 'Call await closeWorld() before replacing a managed World.'
+ );
+
globalSymbols[WorldCache] = world;
- globalSymbols[StubbedWorldCache] = world;
globalSymbols[WorldCachePromise] = undefined;
- globalSymbols[StubbedWorldCachePromise] = undefined;
+ globalSymbols[WorldLifecycleKey] = undefined;
+};
+
+function restoreLifecycleAfterCloseFailure(
+ cleanupWorld: World | undefined,
+ managed: boolean
+): void {
+ if (cleanupWorld) {
+ globalSymbols[WorldLifecycleKey] = {
+ status: 'cleanup',
+ world: cleanupWorld,
+ };
+ return;
+ }
+
+ globalSymbols[WorldLifecycleKey] =
+ managed && (globalSymbols[WorldCache] || globalSymbols[WorldCachePromise])
+ ? { status: 'managed' }
+ : undefined;
+}
+
+/**
+ * Close the cached World without creating one just for cleanup.
+ */
+export const closeWorld = async (): Promise => {
+ const lifecycle = globalSymbols[WorldLifecycleKey];
+ if (lifecycle?.status === 'closing') return lifecycle.promise;
+
+ const cachedWorld = globalSymbols[WorldCache];
+ const cleanupWorld =
+ lifecycle?.status === 'cleanup' ? lifecycle.world : undefined;
+ const worldPromise = cleanupWorld
+ ? Promise.resolve(cleanupWorld)
+ : cachedWorld
+ ? Promise.resolve(cachedWorld)
+ : globalSymbols[WorldCachePromise];
+ if (!worldPromise) return;
+
+ const managed =
+ lifecycle?.status === 'managed' || lifecycle?.status === 'cleanup';
+ const closePromise = (async () => {
+ try {
+ const world = await worldPromise;
+ await world.close?.();
+ globalSymbols[WorldCache] = undefined;
+ globalSymbols[WorldCachePromise] = undefined;
+ globalSymbols[WorldLifecycleKey] = undefined;
+ } catch (error) {
+ if (globalSymbols[WorldLifecycleKey]?.status === 'closing') {
+ restoreLifecycleAfterCloseFailure(cleanupWorld, managed);
+ }
+ throw error;
+ }
+ })();
+ globalSymbols[WorldLifecycleKey] = {
+ status: 'closing',
+ managed,
+ promise: closePromise,
+ };
+ return closePromise;
};
// 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/next/package.json b/packages/next/package.json
index 15981434c2..e08ea4ae04 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..31501731e3 100644
--- a/packages/next/src/builder-eager.ts
+++ b/packages/next/src/builder-eager.ts
@@ -1,5 +1,5 @@
import { constants } from 'node:fs';
-import { access, copyFile, mkdir, stat, writeFile } from 'node:fs/promises';
+import { access, copyFile, mkdir, rm, stat, writeFile } from 'node:fs/promises';
import { extname, join, relative, resolve } from 'node:path';
import type {
NextConfig as BuilderNextConfig,
@@ -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")'
@@ -70,11 +70,12 @@ export async function getNextBuilderEager() {
});
// Expose manifest as a static file when WORKFLOW_PUBLIC_MANIFEST=1.
+ const publicManifestDir = join(
+ this.config.workingDir,
+ 'public/.well-known/workflow/v1'
+ );
+ await rm(join(publicManifestDir, 'manifest.json'), { force: true });
if (this.shouldExposePublicManifest && manifestJson) {
- const publicManifestDir = join(
- this.config.workingDir,
- 'public/.well-known/workflow/v1'
- );
await mkdir(publicManifestDir, { recursive: true });
if (process.env.VERCEL_DEPLOYMENT_ID === undefined) {
await writeFile(join(publicManifestDir, '.gitignore'), '*');
@@ -395,6 +396,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 +438,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..f6f5e46097 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;
@@ -137,12 +139,64 @@ describe('withWorkflow builder config', () => {
);
});
- it('does not prewarm the SWC plugin cache for the production server', async () => {
- const config = withWorkflow({});
+ it('does not load build configuration for the production server', async () => {
+ const projectDir = mkdtempSync(join(realTmpDir, 'workflow-next-start-'));
+ process.chdir(projectDir);
+ writeFile(
+ join(projectDir, 'workflow.config.ts'),
+ `export default { world: './workflow.world.ts' };`
+ );
+ writeFile(
+ join(projectDir, 'workflow.world.ts'),
+ 'export default () => {};'
+ );
+
+ try {
+ const config = withWorkflow({});
+ await config('phase-production-server', { defaultConfig: {} });
+
+ expect(prewarmWorkflowSwcPluginCacheMock).not.toHaveBeenCalled();
+ expect(getNextBuilderMock).not.toHaveBeenCalled();
+ expect(process.env.WORKFLOW_TARGET_WORLD).toBeUndefined();
+ expect(process.env.WORKFLOW_LOCAL_DATA_DIR).toBe('.next/workflow-data');
+ expect(
+ existsSync(
+ join(projectDir, 'node_modules/.cache/workflow/runtime-config.mjs')
+ )
+ ).toBe(false);
+ } finally {
+ process.chdir(originalCwd);
+ rmSync(projectDir, { recursive: true, force: true });
+ }
+ });
+
+ it('resolves the runtime binding from Next.js detected root', async () => {
+ const projectDir = mkdtempSync(join(realTmpDir, 'workflow-next-root-'));
+ process.chdir(projectDir);
+ writeFile(
+ join(projectDir, 'workflow.config.ts'),
+ `export default { world: './workflow.world.ts' };`
+ );
+ writeFile(
+ join(projectDir, 'workflow.world.ts'),
+ 'export default () => {};'
+ );
- await config('phase-production-server', { defaultConfig: {} });
+ try {
+ const config = withWorkflow({});
+ const resolvedConfig = await config('phase-production-build', {
+ defaultConfig: {},
+ });
- expect(prewarmWorkflowSwcPluginCacheMock).not.toHaveBeenCalled();
+ expect(
+ (resolvedConfig.turbopack?.resolveAlias as Record)[
+ '@workflow/config/runtime-binding'
+ ]
+ ).toBe('./node_modules/.cache/workflow/runtime-config.mjs');
+ } finally {
+ process.chdir(originalCwd);
+ rmSync(projectDir, { recursive: true, force: true });
+ }
});
it('configures diagnostics inside the default Next.js dist dir', async () => {
@@ -219,6 +273,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' }
+};`
+ );
+ 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(process.env.WORKFLOW_LOCAL_DATA_DIR).toBe('.next/workflow-data');
+ expect(builderConfigs[0]).toMatchObject({
+ dirs: ['jobs'],
+ projectRoot: '/explicit-root',
+ workflowConfig: {
+ path: join(projectDir, 'workflow.config.ts'),
+ runtimePath: join(
+ projectDir,
+ 'node_modules/.cache/workflow/runtime-config.mjs'
+ ),
+ config: {
+ world: './workflow.world.ts',
+ build: {
+ sourcemap: false,
+ manifest: {
+ public: true,
+ output: 'custom-manifest.json',
+ },
+ },
+ queue: { namespace: 'myapp' },
+ },
+ },
+ });
+ expect(builderConfigs[0]?.externalPackages).toContain(
+ 'configured-external'
+ );
+ const runtimeConfigRequest = relative(
+ turbopackRoot,
+ join(projectDir, 'node_modules/.cache/workflow/runtime-config.mjs')
+ ).replaceAll('\\', '/');
+ expect(
+ (resolvedConfig.turbopack?.resolveAlias as Record)[
+ '@workflow/config/runtime-binding'
+ ]
+ ).toBe(
+ runtimeConfigRequest.startsWith('.')
+ ? runtimeConfigRequest
+ : `./${runtimeConfigRequest}`
+ );
+ } finally {
+ process.chdir(originalCwd);
+ rmSync(projectDir, { recursive: true, force: true });
+ }
+ });
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..c1149d546c 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,29 +332,28 @@ 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 }
) {
+ if (phase === 'phase-production-server') {
+ if (!process.env.VERCEL_DEPLOYMENT_ID) {
+ process.env.WORKFLOW_LOCAL_DATA_DIR ??= '.next/workflow-data';
+ if (workflows?.local?.port !== undefined) {
+ process.env.PORT = workflows.local.port.toString();
+ process.env.WORKFLOW_LOCAL_BASE_URL = `http://localhost:${workflows.local.port}`;
+ }
+ }
+
+ return typeof nextConfigOrFn === 'function'
+ ? await nextConfigOrFn(phase, ctx)
+ : nextConfigOrFn;
+ }
+
if (
phase === 'phase-development-server' ||
phase === 'phase-production-build'
@@ -366,18 +366,35 @@ 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;
+
+ if (!process.env.VERCEL_DEPLOYMENT_ID) {
+ process.env.WORKFLOW_LOCAL_DATA_DIR ??= '.next/workflow-data';
+ if (workflows?.local?.port !== undefined) {
+ process.env.PORT = workflows.local.port.toString();
+ process.env.WORKFLOW_LOCAL_BASE_URL = `http://localhost:${workflows.local.port}`;
+ }
}
+
+ let nextConfig =
+ typeof nextConfigOrFn === 'function'
+ ? 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,8 +458,36 @@ export function withWorkflow(
if (!nextConfig.turbopack.rules) {
nextConfig.turbopack.rules = {};
}
- const existingRules = nextConfig.turbopack.rules as any;
const nextVersion = resolveNextVersion(process.cwd());
+ if (runtimeConfigPath) {
+ const existingResolveAlias = isPlainObject(
+ nextConfig.turbopack.resolveAlias
+ )
+ ? nextConfig.turbopack.resolveAlias
+ : {};
+ const turbopackRoot = resolve(
+ nextConfig.turbopack.root ??
+ nextConfig.outputFileTracingRoot ??
+ (semver.gte(nextVersion, '16.0.0')
+ ? (
+ require('next/dist/lib/find-root') as {
+ findRootDirAndLockFiles(cwd: string): { rootDir: string };
+ }
+ ).findRootDirAndLockFiles(process.cwd()).rootDir
+ : process.cwd())
+ );
+ const runtimeConfigRequest = relative(
+ turbopackRoot,
+ runtimeConfigPath
+ ).replaceAll('\\', '/');
+ nextConfig.turbopack.resolveAlias = {
+ ...existingResolveAlias,
+ '@workflow/config/runtime-binding': runtimeConfigRequest.startsWith('.')
+ ? runtimeConfigRequest
+ : `./${runtimeConfigRequest}`,
+ };
+ }
+ const existingRules = nextConfig.turbopack.rules as any;
const supportsTurboCondition = semver.gte(nextVersion, 'v16.0.0');
const shouldWatch = process.env.NODE_ENV === 'development';
@@ -466,8 +511,8 @@ 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',
@@ -480,10 +525,8 @@ export function withWorkflow(
distDir,
diagnosticsDir: `${distDir}/diagnostics`,
buildTarget: 'next',
- workflowsBundlePath: '', // not used in base
- stepsBundlePath: '', // not used in base
- webhookBundlePath: '', // node used in base
sourcemap: workflows?.sourcemap,
+ workflowConfig: loadedWorkflowConfig,
externalPackages: [
// 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 +593,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)
@@ -557,10 +619,7 @@ export function withWorkflow(
};
// only run this in the main process so it only runs once
// as Next.js uses child processes for different builds
- if (
- !process.env.WORKFLOW_NEXT_PRIVATE_BUILT &&
- phase !== 'phase-production-server'
- ) {
+ if (!process.env.WORKFLOW_NEXT_PRIVATE_BUILT) {
const workflowBuilder = await getWorkflowBuilder();
await workflowBuilder.build();
diff --git a/packages/nitro/package.json b/packages/nitro/package.json
index cab9ec1775..774bd9cee7 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..951b3b40a1 100644
--- a/packages/nitro/src/builders.ts
+++ b/packages/nitro/src/builders.ts
@@ -4,39 +4,43 @@ import {
createBaseBuilderConfig,
VercelBuildOutputAPIBuilder,
} from '@workflow/builders';
+import type { LoadedWorkflowConfig } from '@workflow/config/load';
import type { Nitro } from 'nitro/types';
import { join } 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 ?? ['.'],
+ sourcemap: nitro.options.workflow?.sourcemap,
+ externalPackages: externalPackages.length ? 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 +59,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..901e57e6c4 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) {
@@ -33,8 +61,9 @@ function createNitroStub({
dev,
externals: externals ?? {},
handlers: [],
+ plugins: [],
preset,
- rootDir: '/tmp/project',
+ rootDir,
typescript: {},
vercel: vercel ?? {},
virtual: {},
@@ -108,6 +137,129 @@ describe('@workflow/nitro virtual handlers', () => {
);
}
});
+
+ it('does not import config from unbundled dev routes without a config file', async () => {
+ const nitro = createNitroStub({ routing: false, dev: true });
+
+ await nitroModule.setup(nitro);
+
+ const source = nitro.options.virtual['#workflow/workflows.mjs'];
+ expect(source).not.toContain('@workflow/config');
+ });
+
+ it('installs configured runtime values as a Nitro plugin', async () => {
+ const project = createProject(
+ `export default { queue: { namespace: 'app' } };`
+ );
+ const nitro = createNitroStub({
+ routing: false,
+ dev: true,
+ rootDir: project,
+ });
+
+ await nitroModule.setup(nitro);
+
+ expect(nitro.options.plugins[0]).toBe('#workflow/runtime-config');
+ expect(nitro.options.virtual['#workflow/runtime-config']).toContain(
+ 'import "@workflow/config/runtime-binding";'
+ );
+ });
+
+ it('keeps configured World providers in lazy chunks', async () => {
+ const project = createProject(
+ `export default { world: './workflow.world.ts' };`
+ );
+ writeFileSync(
+ join(project, 'workflow.world.ts'),
+ `throw new Error('must stay lazy');`
+ );
+ const nitro = createNitroStub({ routing: true, rootDir: project });
+
+ await nitroModule.setup(nitro);
+
+ expect(nitro.options.inlineDynamicImports).toBe(false);
+ });
+});
+
+describe('@workflow/nitro workflow.config.ts', () => {
+ it('applies typed Nitro settings and a namespaced queue trigger', async () => {
+ const project = createProject(
+ `export default {
+ build: {
+ dirs: ['server/jobs'],
+ sourcemap: false,
+ manifest: { public: true }
+ },
+ queue: { namespace: 'myapp' },
+ integration: {
+ type: 'nitro',
+ typescriptPlugin: true,
+ runtime: 'nodejs24.x'
+ }
+};`
+ );
+ const nitro = createNitroStub({
+ routing: true,
+ preset: 'vercel',
+ rootDir: project,
+ });
+
+ await nitroModule.setup(nitro);
+
+ expect(nitro.options.workflow).toMatchObject({
+ dirs: ['server/jobs'],
+ typescriptPlugin: true,
+ runtime: 'nodejs24.x',
+ });
+ expect(
+ nitro.options.typescript.tsConfig.compilerOptions.plugins
+ ).toContainEqual({ name: 'workflow' });
+ expect(
+ nitro.options.vercel.functionRules['/.well-known/workflow/v1/flow']
+ .experimentalTriggers
+ ).toEqual([
+ expect.objectContaining({
+ type: 'queue/v2beta',
+ topic: '__myapp_wkf_workflow_*',
+ }),
+ ]);
+ expect(
+ nitro.options.handlers.some(
+ (handler: { route: string }) =>
+ handler.route === '/.well-known/workflow/v1/manifest.json'
+ )
+ ).toBe(true);
+ expect(nitro.options.plugins).toContain('#workflow/runtime-config');
+ });
+
+ it('prefers environment variables over workflow.config.ts', async () => {
+ const project = createProject(
+ `export default {
+ build: { manifest: { public: true } },
+ queue: { namespace: 'configured' }
+};`
+ );
+ process.env.WORKFLOW_QUEUE_NAMESPACE = 'environment';
+ process.env.WORKFLOW_PUBLIC_MANIFEST = '0';
+ const nitro = createNitroStub({
+ routing: true,
+ preset: 'vercel',
+ rootDir: project,
+ });
+
+ await nitroModule.setup(nitro);
+
+ expect(
+ nitro.options.vercel.functionRules['/.well-known/workflow/v1/flow']
+ .experimentalTriggers[0].topic
+ ).toBe('__environment_wkf_workflow_*');
+ expect(
+ nitro.options.handlers.some(
+ (handler: { route: string }) =>
+ handler.route === '/.well-known/workflow/v1/manifest.json'
+ )
+ ).toBe(false);
+ });
});
describe('@workflow/nitro Vercel functionRules', () => {
@@ -246,10 +398,14 @@ describe('@workflow/nitro Vercel functionRules', () => {
// routes, so we must NOT touch functionRules — and we must register a
// `compiled` hook that runs the VercelBuilder.
const compiledHooks: Array<() => void> = [];
+ const project = createProject(
+ `export default { queue: { namespace: 'legacy' } };`
+ );
const nitro = createNitroStub({
routing: false,
majorVersion: 2,
preset: 'vercel',
+ rootDir: project,
});
nitro.hooks.hook = (name: string, fn: () => void) => {
if (name === 'compiled') compiledHooks.push(fn);
@@ -259,6 +415,7 @@ describe('@workflow/nitro Vercel functionRules', () => {
expect(nitro.options.vercel?.functionRules ?? {}).toEqual({});
expect(compiledHooks.length).toBe(1);
+ expect(nitro.options.plugins).toContain('#workflow/runtime-config');
});
});
@@ -309,6 +466,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 +479,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 +488,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 +499,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 +508,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..5c6e5d14e0 100644
--- a/packages/nitro/src/index.ts
+++ b/packages/nitro/src/index.ts
@@ -1,15 +1,18 @@
-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 type { Nitro, RollupConfig } from 'nitro/types';
import { join } from 'pathe';
import { LocalBuilder, VercelBuilder } from './builders.js';
import type { ModuleOptions } from './types';
export type { ModuleOptions };
+const RUNTIME_CONFIG_PLUGIN_ID = '#workflow/runtime-config';
+
/**
* Detect whether the Nitro instance is v2.
* Newer Nitro releases (both v2 and v3) expose `nitro.meta.majorVersion`.
@@ -26,9 +29,45 @@ 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;
+ if (workflowConfig.world) {
+ nitro.options.inlineDynamicImports = false;
+ }
+ const nitroIntegration =
+ workflowConfig.integration?.type === 'nitro'
+ ? workflowConfig.integration
+ : undefined;
+ nitro.options.workflow = {
+ ...nitro.options.workflow,
+ dirs: nitro.options.workflow?.dirs ?? workflowConfig.build?.dirs,
+ typescriptPlugin:
+ nitro.options.workflow?.typescriptPlugin ??
+ nitroIntegration?.typescriptPlugin,
+ runtime: nitro.options.workflow?.runtime ?? nitroIntegration?.runtime,
+ };
+ const publicManifest =
+ process.env.WORKFLOW_PUBLIC_MANIFEST === undefined
+ ? (workflowConfig.build?.manifest?.public ?? false)
+ : process.env.WORKFLOW_PUBLIC_MANIFEST === '1';
+ const workflowQueueTrigger = createWorkflowQueueTrigger({
+ namespace:
+ process.env.WORKFLOW_QUEUE_NAMESPACE ?? workflowConfig.queue?.namespace,
+ });
+ if (runtimeConfigPath) {
+ nitro.options.virtual[RUNTIME_CONFIG_PLUGIN_ID] = `
+ import "@workflow/config/runtime-binding";
+ export default () => {};
+ `;
+ nitro.options.plugins.unshift(RUNTIME_CONFIG_PLUGIN_ID);
+ }
const isVercelDeploy =
!nitro.options.dev && nitro.options.preset === 'vercel';
@@ -38,7 +77,21 @@ 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: {
+ order: 'pre',
+ handler(source: string) {
+ return source === '@workflow/config/runtime-binding'
+ ? { id: runtimeConfigPath, external: false }
+ : null;
+ },
+ },
+ });
+ }
+ plugins.push(
workflowTransformPlugin({
// Exclude pre-built workflow bundles from re-transformation
// These are already processed and re-processing causes issues like
@@ -46,11 +99,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 +217,7 @@ export default {
if (useLegacyVercelBuild) {
nitro.hooks.hook('compiled', async () => {
- await new VercelBuilder(nitro).build();
+ await new VercelBuilder(nitro, loadedWorkflowConfig).build();
});
}
@@ -174,19 +228,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 +250,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
@@ -256,14 +307,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 +322,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,9 +339,13 @@ export default {
}
addManifestHandler(nitro);
}
+
+ return localBuilder;
}
},
-} satisfies NitroModule;
+};
+
+export default nitroModule;
const DASHBOARD_VIRTUAL_ID = '#workflow/dashboard-handler';
@@ -368,7 +423,6 @@ function addVirtualHandler(nitro: Nitro, route: string, buildPath: string) {
const handlerImportPath = JSON.stringify(
join(nitro.options.buildDir, buildPath)
);
-
if (nitro.options.dev) {
// Dev mode: load generated workflow bundles from disk at request time.
// This keeps `.nitro/workflow/*.mjs` out of Nitro's own bundle graph,
@@ -379,7 +433,6 @@ function addVirtualHandler(nitro: Nitro, route: string, buildPath: string) {
import { fromWebHandler } from "h3";
import { statSync } from "node:fs";
import { pathToFileURL } from "node:url";
-
const handlerPath = ${handlerImportPath};
let currentVersion = "";
let currentImportPath = "";
@@ -402,7 +455,6 @@ function addVirtualHandler(nitro: Nitro, route: string, buildPath: string) {
nitro.options.virtual[`#${buildPath}`] = /* js */ `
import { statSync } from "node:fs";
import { pathToFileURL } from "node:url";
-
const handlerPath = ${handlerImportPath};
let currentVersion = "";
let currentImportPath = "";
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/sveltekit/package.json b/packages/sveltekit/package.json
index 292092fdc7..626edbd074 100644
--- a/packages/sveltekit/package.json
+++ b/packages/sveltekit/package.json
@@ -27,6 +27,7 @@
"dependencies": {
"@swc/core": "catalog:",
"@workflow/builders": "workspace:*",
+ "@workflow/config": "workspace:*",
"@workflow/rollup": "workspace:*",
"@workflow/swc-plugin": "workspace:*",
"@workflow/vite": "workspace:*",
diff --git a/packages/sveltekit/src/builder.ts b/packages/sveltekit/src/builder.ts
index 3ed5011993..c45b2f72bc 100644
--- a/packages/sveltekit/src/builder.ts
+++ b/packages/sveltekit/src/builder.ts
@@ -23,19 +23,20 @@ const SVELTEKIT_VIRTUAL_MODULES = [
];
export class SvelteKitBuilder extends BaseBuilder {
- constructor(config?: Partial) {
- const workingDir = config?.workingDir || process.cwd();
+ constructor(config: Partial = {}) {
+ const workingDir = config.workingDir ?? process.cwd();
+ const build = config.workflowConfig?.config.build;
super({
...config,
- dirs: ['workflows', 'src/workflows', 'routes', 'src/routes'],
+ dirs: config.dirs ??
+ build?.dirs ?? ['workflows', 'src/workflows', 'routes', 'src/routes'],
buildTarget: 'sveltekit' as const,
- stepsBundlePath: '', // unused in base
- workflowsBundlePath: '', // unused in base
- webhookBundlePath: '', // unused in base
workingDir,
- externalPackages: [...SVELTEKIT_VIRTUAL_MODULES],
- sourcemap: config?.sourcemap,
+ externalPackages: [
+ ...SVELTEKIT_VIRTUAL_MODULES,
+ ...(config.externalPackages ?? build?.externalPackages ?? []),
+ ],
});
}
@@ -103,11 +104,12 @@ export const POST = async ({request}) => {
// Expose manifest as a static file when WORKFLOW_PUBLIC_MANIFEST=1.
// SvelteKit serves files from static/ at the root URL.
+ const staticManifestDir = join(
+ this.config.workingDir,
+ 'static/.well-known/workflow/v1'
+ );
+ await rm(join(staticManifestDir, 'manifest.json'), { force: true });
if (this.shouldExposePublicManifest && manifestJson) {
- const staticManifestDir = join(
- this.config.workingDir,
- 'static/.well-known/workflow/v1'
- );
await mkdir(staticManifestDir, { recursive: true });
if (process.env.VERCEL_DEPLOYMENT_ID === undefined) {
await writeFile(join(staticManifestDir, '.gitignore'), '*');
diff --git a/packages/sveltekit/src/index.ts b/packages/sveltekit/src/index.ts
index d3f4a18a64..4cb2c193ca 100644
--- a/packages/sveltekit/src/index.ts
+++ b/packages/sveltekit/src/index.ts
@@ -1,22 +1,20 @@
import path from 'node:path';
-import { WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders';
+import { createWorkflowQueueTrigger } from '@workflow/builders';
import fs from 'fs-extra';
-import { SvelteKitBuilder } from './builder.js';
+import { loadedWorkflowConfig } from './plugin.js';
import { stripWorkflowQueueTriggers } from './vc-config.js';
-const builder = new SvelteKitBuilder();
-
-// This needs to be in the top-level as we need to create these
-// entries before svelte plugin is started or the entries are
-// a race to be created before svelte discovers entries
-await builder.build();
-
process.on('beforeExit', () => {
// Don't patch functions output if not in Vercel adapter
if (!process.env.VERCEL_DEPLOYMENT_ID) {
return;
}
+ const workflowQueueTrigger = createWorkflowQueueTrigger({
+ namespace:
+ process.env.WORKFLOW_QUEUE_NAMESPACE ??
+ loadedWorkflowConfig.config.queue?.namespace,
+ });
// V2: Only the combined flow handler needs queue triggers.
// The separate step route was removed.
for (const { file, config } of [
@@ -24,7 +22,7 @@ process.on('beforeExit', () => {
file: '.vercel/output/functions/.well-known/workflow/v1/flow.func/.vc-config.json',
config: {
maxDuration: 'max',
- experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER],
+ experimentalTriggers: [workflowQueueTrigger],
},
},
]) {
diff --git a/packages/sveltekit/src/plugin.ts b/packages/sveltekit/src/plugin.ts
index 28193621a0..5efeb8d359 100644
--- a/packages/sveltekit/src/plugin.ts
+++ b/packages/sveltekit/src/plugin.ts
@@ -1,9 +1,16 @@
import { createBuildQueue } from '@workflow/builders';
+import type { SourcemapMode } from '@workflow/config';
+import { loadWorkflowConfig } from '@workflow/config/load';
import { workflowTransformPlugin } from '@workflow/rollup';
import { workflowHotUpdatePlugin } from '@workflow/vite';
import type { Plugin } from 'vite';
import { SvelteKitBuilder } from './builder.js';
+export const loadedWorkflowConfig = await loadWorkflowConfig({
+ cwd: process.cwd(),
+ integration: 'sveltekit',
+});
+
export interface WorkflowPluginOptions {
/**
* Controls how source maps are emitted for workflow bundles. Accepts the
@@ -11,17 +18,34 @@ export interface WorkflowPluginOptions {
* `'linked'`, `'external'`, `'both'`, or `false` to omit source maps. Can
* also be set via the `WORKFLOW_SOURCEMAP` environment variable.
*/
- sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both';
+ sourcemap?: SourcemapMode;
}
export function workflowPlugin(options: WorkflowPluginOptions = {}): Plugin[] {
- const builder = new SvelteKitBuilder({ sourcemap: options.sourcemap });
+ const builder = new SvelteKitBuilder({
+ sourcemap: options.sourcemap,
+ workflowConfig: loadedWorkflowConfig,
+ });
const enqueue = createBuildQueue();
+ const initialBuild = builder.build();
return [
workflowTransformPlugin() as Plugin,
{
name: 'workflow:sveltekit',
+ enforce: 'pre',
+ async config() {
+ await initialBuild;
+ if (!loadedWorkflowConfig.runtimePath) return;
+ return {
+ ssr: { noExternal: ['workflow', '@workflow/core'] },
+ };
+ },
+ resolveId(source) {
+ if (source === '@workflow/config/runtime-binding') {
+ return loadedWorkflowConfig.runtimePath;
+ }
+ },
},
workflowHotUpdatePlugin({
builder,
diff --git a/packages/sveltekit/src/vc-config.test.ts b/packages/sveltekit/src/vc-config.test.ts
index ef8df10704..52401db697 100644
--- a/packages/sveltekit/src/vc-config.test.ts
+++ b/packages/sveltekit/src/vc-config.test.ts
@@ -33,6 +33,16 @@ describe('stripWorkflowQueueTriggersFromConfig', () => {
});
});
+ it('removes namespaced workflow queue triggers', () => {
+ expect(
+ stripWorkflowQueueTriggersFromConfig({
+ experimentalTriggers: [
+ { ...WORKFLOW_QUEUE_TRIGGER, topic: '__myapp_wkf_workflow_*' },
+ ],
+ })
+ ).toEqual({});
+ });
+
it('leaves configs without workflow triggers unchanged', () => {
const config = {
runtime: 'nodejs',
diff --git a/packages/sveltekit/src/vc-config.ts b/packages/sveltekit/src/vc-config.ts
index 3c9fc50e42..13a2201039 100644
--- a/packages/sveltekit/src/vc-config.ts
+++ b/packages/sveltekit/src/vc-config.ts
@@ -1,15 +1,15 @@
-import { WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders';
import fs from 'fs-extra';
-const WORKFLOW_QUEUE_TOPICS = new Set([WORKFLOW_QUEUE_TRIGGER.topic]);
-
function isWorkflowQueueTrigger(trigger: unknown) {
if (typeof trigger !== 'object' || trigger === null) {
return false;
}
const topic = (trigger as { topic?: unknown }).topic;
- return typeof topic === 'string' && WORKFLOW_QUEUE_TOPICS.has(topic);
+ return (
+ typeof topic === 'string' &&
+ /^__(?:[a-z][a-z0-9]*_)?wkf_workflow_\*$/.test(topic)
+ );
}
export function stripWorkflowQueueTriggersFromConfig<
diff --git a/packages/web/app/server/workflow-server-actions.server.test.ts b/packages/web/app/server/workflow-server-actions.server.test.ts
new file mode 100644
index 0000000000..5e31dec1d2
--- /dev/null
+++ b/packages/web/app/server/workflow-server-actions.server.test.ts
@@ -0,0 +1,29 @@
+import type { World } from '@workflow/world';
+import { afterEach, expect, it } from 'vitest';
+import { getPublicServerConfig } from './workflow-server-actions.server.js';
+
+const runtimeConfig = Symbol.for('@workflow/config/runtime');
+const globals = globalThis as typeof globalThis & {
+ [key: symbol]: { world?: () => World } | undefined;
+};
+const targetWorld = process.env.WORKFLOW_TARGET_WORLD;
+const deploymentId = process.env.VERCEL_DEPLOYMENT_ID;
+
+afterEach(() => {
+ delete globals[runtimeConfig];
+ if (targetWorld === undefined) delete process.env.WORKFLOW_TARGET_WORLD;
+ else process.env.WORKFLOW_TARGET_WORLD = targetWorld;
+ if (deploymentId === undefined) delete process.env.VERCEL_DEPLOYMENT_ID;
+ else process.env.VERCEL_DEPLOYMENT_ID = deploymentId;
+});
+
+it('identifies a configured World without treating it as local', async () => {
+ delete process.env.WORKFLOW_TARGET_WORLD;
+ delete process.env.VERCEL_DEPLOYMENT_ID;
+ globals[runtimeConfig] = { world: () => ({}) as World };
+
+ await expect(getPublicServerConfig()).resolves.toMatchObject({
+ backendId: 'configured',
+ backendDisplayName: 'Configured',
+ });
+});
diff --git a/packages/web/app/server/workflow-server-actions.server.ts b/packages/web/app/server/workflow-server-actions.server.ts
index 3bdfe975fb..a93c796088 100644
--- a/packages/web/app/server/workflow-server-actions.server.ts
+++ b/packages/web/app/server/workflow-server-actions.server.ts
@@ -8,7 +8,7 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import * as workflowRunHelpers from '@workflow/core/runtime';
-import { createWorld } from '@workflow/core/runtime';
+import { getWorld } from '@workflow/core/runtime';
import {
type HealthCheckEndpoint,
type HealthCheckResult,
@@ -16,7 +16,7 @@ import {
} from '@workflow/core/runtime/helpers';
import { resumeHook as resumeHookRuntime } from '@workflow/core/runtime/resume-hook';
-import { WorkflowWorldError, WorkflowRunNotFoundError } from '@workflow/errors';
+import { WorkflowRunNotFoundError, WorkflowWorldError } from '@workflow/errors';
import { findWorkflowDataDir } from '@workflow/utils/check-data-dir';
import type {
Event,
@@ -26,7 +26,7 @@ import type {
WorkflowRunStatus,
World,
} from '@workflow/world';
-import { type APIConfig, createVercelWorld } from '@workflow/world-vercel';
+import { createVercelWorld } from '@workflow/world-vercel';
/**
* Environment variable map for world configuration.
@@ -80,6 +80,8 @@ function getBackendDisplayName(targetWorld: string | undefined): string {
return 'Local';
case 'vercel':
return 'Vercel';
+ case 'configured':
+ return 'Configured';
case '@workflow/world-postgres':
case 'postgres':
return 'PostgreSQL';
@@ -99,6 +101,9 @@ function getEffectiveBackendId(): string {
if (targetWorld) {
return targetWorld;
}
+ if (workflowRunHelpers.usesConfiguredWorld()) {
+ return 'configured';
+ }
// Match @workflow/core/runtime defaulting: vercel if VERCEL_DEPLOYMENT_ID is set, else local.
return process.env.VERCEL_DEPLOYMENT_ID ? 'vercel' : 'local';
}
@@ -383,15 +388,6 @@ export type ServerActionResult =
| { success: true; data: T }
| { success: false; error: ServerActionError };
-/**
- * Cache for World instances.
- *
- * IMPORTANT:
- * - We only cache non-vercel worlds.
- * - Cache keys are derived from **server-side** WORKFLOW_* env vars only.
- */
-const worldCache = new Map();
-
/**
* Get or create a World instance based on configuration.
*
@@ -435,21 +431,7 @@ async function getWorldFromEnv(userEnvMap: EnvMap): Promise {
await ensureLocalWorldDataDirEnv();
}
- // Cache key derived ONLY from WORKFLOW_* env vars.
- const workflowEnvEntries = Object.entries(process.env).filter(([key]) =>
- key.startsWith('WORKFLOW_')
- );
- workflowEnvEntries.sort(([a], [b]) => a.localeCompare(b));
- const cacheKey = JSON.stringify(Object.fromEntries(workflowEnvEntries));
-
- const cachedWorld = worldCache.get(cacheKey);
- if (cachedWorld) {
- return cachedWorld;
- }
-
- const world = await createWorld();
- worldCache.set(cacheKey, world);
- return world;
+ return getWorld();
}
/**
diff --git a/packages/workflow/package.json b/packages/workflow/package.json
index 6bb25de16a..9ce4dd4cc5 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/workflow/src/runtime.ts b/packages/workflow/src/runtime.ts
index 9d8fcb20fa..5decdd4348 100644
--- a/packages/workflow/src/runtime.ts
+++ b/packages/workflow/src/runtime.ts
@@ -1,4 +1,5 @@
export {
+ closeWorld,
createWorld,
getWorld,
getWorldHandlers,
diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts
index f028dccc3d..39f57d4030 100644
--- a/packages/world-local/src/index.ts
+++ b/packages/world-local/src/index.ts
@@ -2,7 +2,12 @@ import { promises as fs } from 'node:fs';
import { rm } from 'node:fs/promises';
import path from 'node:path';
import type { QueuePrefix, World } from '@workflow/world';
-import { reenqueueActiveRuns, SPEC_VERSION_CURRENT } from '@workflow/world';
+import {
+ getQueueTopicPrefix,
+ reenqueueActiveRuns,
+ resolveQueueNamespace,
+ SPEC_VERSION_CURRENT,
+} from '@workflow/world';
import type { Config } from './config.js';
import { config } from './config.js';
import {
@@ -88,7 +93,11 @@ export function createLocalWorld(args?: Partial): LocalWorld {
})) as typeof storage.runs.list,
}
: storage.runs;
- await reenqueueActiveRuns(recoveryRuns, queue.queue, 'world-local');
+ await reenqueueActiveRuns(
+ recoveryRuns,
+ queue.queue,
+ getQueueTopicPrefix('workflow', resolveQueueNamespace())
+ );
},
async close() {
await queue.close();
diff --git a/packages/world-local/src/reenqueue.test.ts b/packages/world-local/src/reenqueue.test.ts
index 2759f1966c..d6c1a95fea 100644
--- a/packages/world-local/src/reenqueue.test.ts
+++ b/packages/world-local/src/reenqueue.test.ts
@@ -1,8 +1,8 @@
+import { rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
-import { rm } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { createLocalWorld, type LocalWorld } from './index.js';
+import { createLocalWorld } from './index.js';
import { createRun, updateRun } from './test-helpers.js';
// Mock node:timers/promises so the queue's setTimeout resolves immediately
@@ -18,6 +18,7 @@ describe('re-enqueue active runs on start', () => {
});
afterEach(async () => {
+ delete process.env.WORKFLOW_QUEUE_NAMESPACE;
await rm(dataDir, { recursive: true, force: true });
});
@@ -121,6 +122,29 @@ describe('re-enqueue active runs on start', () => {
await world2.close();
});
+ it('uses the active queue namespace', async () => {
+ const world1 = createLocalWorld({ dataDir });
+ await world1.start();
+ const run = await createRun(world1, {
+ deploymentId: 'dpl_1',
+ workflowName: 'myWorkflow',
+ input: new Uint8Array([1]),
+ });
+ await world1.close();
+
+ process.env.WORKFLOW_QUEUE_NAMESPACE = 'myapp';
+ const world2 = createLocalWorld({ dataDir });
+ const receivedRunIds: string[] = [];
+ world2.registerHandler('__myapp_wkf_workflow_', async (req) => {
+ receivedRunIds.push((await req.json()).runId);
+ return Response.json({ ok: true });
+ });
+
+ await world2.start();
+ await vi.waitFor(() => expect(receivedRunIds).toEqual([run.runId]));
+ await world2.close();
+ });
+
it('only re-enqueues runs for the matching tag', async () => {
const world0 = createLocalWorld({ dataDir, tag: 'vitest-0' });
await world0.start();
diff --git a/packages/world-postgres/HOW_IT_WORKS.md b/packages/world-postgres/HOW_IT_WORKS.md
index 4c13a2a20e..e6e4069a98 100644
--- a/packages/world-postgres/HOW_IT_WORKS.md
+++ b/packages/world-postgres/HOW_IT_WORKS.md
@@ -19,7 +19,7 @@ graph LR
PG -.-> S["${prefix}steps
(steps)"]
```
-Jobs include retry logic (3 attempts), idempotency keys, durable delayed rescheduling, and configurable worker concurrency (default: 10).
+Jobs include retry logic (3 attempts), idempotency keys, durable delayed rescheduling, and configurable worker concurrency (default: 50).
## Streaming
@@ -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-postgres/src/config.ts b/packages/world-postgres/src/config.ts
index 2c74ff05c9..943e022311 100644
--- a/packages/world-postgres/src/config.ts
+++ b/packages/world-postgres/src/config.ts
@@ -8,7 +8,7 @@ export type PostgresWorldConfig = PgConnectionConfig & {
jobPrefix?: string;
/**
* namespace for queue topic prefixes (e.g. 'custom' → '__custom_wkf_workflow_').
- * defaults to WORKFLOW_QUEUE_NAMESPACE env var if not provided.
+ * Used only when no runtime queue namespace is configured.
*/
namespace?: string;
queueConcurrency?: number;
diff --git a/packages/world-postgres/src/index.ts b/packages/world-postgres/src/index.ts
index 9ad7565e06..6ade926518 100644
--- a/packages/world-postgres/src/index.ts
+++ b/packages/world-postgres/src/index.ts
@@ -1,5 +1,11 @@
import type { Storage, World } from '@workflow/world';
-import { reenqueueActiveRuns, SPEC_VERSION_CURRENT } from '@workflow/world';
+import {
+ getQueueTopicPrefix,
+ reenqueueActiveRuns,
+ resolveQueueNamespace,
+ SPEC_VERSION_CURRENT,
+} from '@workflow/world';
+import { setWorkflowQueueNamespace } from '@workflow/world/queue.js';
import { Pool } from 'pg';
import type { PostgresWorldConfig } from './config.js';
import { createClient, type Drizzle } from './drizzle/index.js';
@@ -41,6 +47,7 @@ export function createWorld(
50,
}
): World & { start(): Promise } {
+ let usesProviderNamespace = false;
const maxPoolSize = config.maxPoolSize ?? getDefaultMaxPoolSize();
const pool =
config.pool ||
@@ -65,8 +72,16 @@ export function createWorld(
streamFlushIntervalMs: config.streamFlushIntervalMs,
}),
async start() {
+ if (resolveQueueNamespace() === undefined && config.namespace) {
+ setWorkflowQueueNamespace(config.namespace);
+ usesProviderNamespace = true;
+ }
await queue.start();
- await reenqueueActiveRuns(storage.runs, queue.queue, 'world-postgres');
+ await reenqueueActiveRuns(
+ storage.runs,
+ queue.queue,
+ getQueueTopicPrefix('workflow', resolveQueueNamespace())
+ );
},
async close() {
await streamer.close();
@@ -74,6 +89,9 @@ export function createWorld(
if (pool !== config.pool) {
await pool.end();
}
+ if (usesProviderNamespace) {
+ setWorkflowQueueNamespace(undefined);
+ }
},
};
}
diff --git a/packages/world-postgres/src/queue.test.ts b/packages/world-postgres/src/queue.test.ts
index 83cf885cff..a49916f3b9 100644
--- a/packages/world-postgres/src/queue.test.ts
+++ b/packages/world-postgres/src/queue.test.ts
@@ -2,6 +2,7 @@ import { createServer, type Server } from 'node:http';
import { JsonTransport } from '@vercel/queue';
import { getWorkflowPort } from '@workflow/utils/get-port';
import { MessageId, parseQueueName, type QueuePayload } from '@workflow/world';
+import { setWorkflowQueueNamespace } from '@workflow/world/queue.js';
import { createLocalWorld } from '@workflow/world-local';
import { makeWorkerUtils, run, type WorkerUtils } from 'graphile-worker';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -14,9 +15,7 @@ const createdQueues: Array> = [];
const createdServers: Server[] = [];
vi.mock('graphile-worker', () => ({
- Logger: class Logger {
- constructor(_: unknown) {}
- },
+ Logger: class Logger {},
makeWorkerUtils: vi.fn(),
run: vi.fn(),
}));
@@ -52,6 +51,8 @@ describe('postgres queue http execution', () => {
beforeEach(() => {
vi.clearAllMocks();
+ setWorkflowQueueNamespace(undefined);
+ delete process.env.WORKFLOW_QUEUE_NAMESPACE;
vi.mocked(makeWorkerUtils).mockResolvedValue(workerUtilsMock);
vi.mocked(getWorkflowPort).mockResolvedValue(undefined);
@@ -74,6 +75,7 @@ describe('postgres queue http execution', () => {
);
vi.useRealTimers();
delete process.env.WORKFLOW_LOCAL_BASE_URL;
+ delete process.env.WORKFLOW_QUEUE_NAMESPACE;
delete process.env.PORT;
});
@@ -256,7 +258,7 @@ describe('postgres queue http execution', () => {
}
});
- it('serializes namespaced workflow queue execution for the same runId', async () => {
+ it('uses the runtime namespace before the provider fallback', async () => {
let resolveFirstRequestStarted!: () => void;
const firstRequestStarted = new Promise((resolve) => {
resolveFirstRequestStarted = resolve;
@@ -283,6 +285,7 @@ describe('postgres queue http execution', () => {
});
vi.stubGlobal('fetch', fetchMock);
process.env.WORKFLOW_LOCAL_BASE_URL = 'http://localhost:3000';
+ process.env.WORKFLOW_QUEUE_NAMESPACE = 'preview';
const queue = buildQueue(
{ connectionString: 'postgres://test', namespace: 'custom' },
@@ -296,13 +299,13 @@ describe('postgres queue http execution', () => {
runId: 'wrun_01ABC',
};
const firstExecution = task(
- buildMessageData('__custom_wkf_workflow_test-workflow', payload, {
+ buildMessageData('__preview_wkf_workflow_test-workflow', payload, {
messageId: MessageId.parse('msg_01ABC'),
}),
{} as any
);
const secondExecution = task(
- buildMessageData('__custom_wkf_workflow_test-workflow', payload, {
+ buildMessageData('__preview_wkf_workflow_test-workflow', payload, {
messageId: MessageId.parse('msg_01ABD'),
}),
{} as any
diff --git a/packages/world-postgres/src/queue.ts b/packages/world-postgres/src/queue.ts
index 00db4be87d..25e0be8d90 100644
--- a/packages/world-postgres/src/queue.ts
+++ b/packages/world-postgres/src/queue.ts
@@ -488,7 +488,7 @@ export function createQueue(
string,
(payload: unknown, helpers: unknown) => Promise
> = {};
- const namespace = resolveQueueNamespace(config.namespace);
+ const namespace = resolveQueueNamespace() ?? config.namespace;
const workflowPrefix = getQueueTopicPrefix('workflow', namespace);
const stepPrefix = getQueueTopicPrefix('step', namespace);
taskList[getJobQueueName(workflowPrefix)] =
diff --git a/packages/world-postgres/src/reenqueue.test.ts b/packages/world-postgres/src/reenqueue.test.ts
index 5cd6b49b42..1fd01062bc 100644
--- a/packages/world-postgres/src/reenqueue.test.ts
+++ b/packages/world-postgres/src/reenqueue.test.ts
@@ -1,11 +1,7 @@
import { getWorkflowPort } from '@workflow/utils/get-port';
+import { setWorkflowQueueNamespace } from '@workflow/world/queue.js';
import { createLocalWorld } from '@workflow/world-local';
-import {
- Logger,
- makeWorkerUtils,
- run,
- type WorkerUtils,
-} from 'graphile-worker';
+import { makeWorkerUtils, run, type WorkerUtils } from 'graphile-worker';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createWorld } from './index.js';
import {
@@ -14,12 +10,9 @@ import {
createRunsStorage,
createStepsStorage,
} from './storage.js';
-import { createStreamer } from './streamer.js';
vi.mock('graphile-worker', () => ({
- Logger: class Logger {
- constructor(_: unknown) {}
- },
+ Logger: class Logger {},
makeWorkerUtils: vi.fn(),
run: vi.fn(),
}));
@@ -95,6 +88,8 @@ describe('re-enqueue active runs on start', () => {
beforeEach(() => {
vi.clearAllMocks();
+ setWorkflowQueueNamespace(undefined);
+ delete process.env.WORKFLOW_QUEUE_NAMESPACE;
vi.mocked(makeWorkerUtils).mockResolvedValue(workerUtilsMock);
vi.mocked(getWorkflowPort).mockResolvedValue(undefined);
vi.mocked(run).mockResolvedValue(runnerMock as any);
@@ -112,6 +107,7 @@ describe('re-enqueue active runs on start', () => {
afterEach(async () => {
delete process.env.WORKFLOW_LOCAL_BASE_URL;
+ delete process.env.WORKFLOW_QUEUE_NAMESPACE;
delete process.env.PORT;
});
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/packages/world/src/recovery.ts b/packages/world/src/recovery.ts
index d20cfa33c1..43e015ca01 100644
--- a/packages/world/src/recovery.ts
+++ b/packages/world/src/recovery.ts
@@ -1,6 +1,5 @@
-import type { Queue } from './queue.js';
import type { Storage } from './interfaces.js';
-import type { ValidQueueName } from './queue.js';
+import type { Queue, QueuePrefix, ValidQueueName } from './queue.js';
/**
* Re-enqueue all active (pending/running) workflow runs so they resume
@@ -9,12 +8,12 @@ import type { ValidQueueName } from './queue.js';
*
* @param runs - Storage runs interface for listing active runs
* @param enqueue - Queue's enqueue method
- * @param label - Log prefix for identifying the world implementation (e.g. "world-local")
+ * @param workflowPrefix - Active workflow queue prefix
*/
export async function reenqueueActiveRuns(
runs: Storage['runs'],
enqueue: Queue['queue'],
- label: string
+ workflowPrefix: QueuePrefix
): Promise {
let reenqueued = 0;
for (const status of ['pending', 'running'] as const) {
@@ -28,12 +27,13 @@ export async function reenqueueActiveRuns(
});
for (const run of page.data) {
try {
- const queueName: ValidQueueName = `__wkf_workflow_${run.workflowName}`;
+ const queueName =
+ `${workflowPrefix}${run.workflowName}` as ValidQueueName;
await enqueue(queueName, { runId: run.runId });
reenqueued++;
} catch (err) {
console.warn(
- `[${label}] Failed to re-enqueue run ${run.runId}: ${err}`
+ `[workflow] Failed to re-enqueue run ${run.runId}: ${err}`
);
}
}
@@ -43,7 +43,7 @@ export async function reenqueueActiveRuns(
}
if (reenqueued > 0) {
console.log(
- `[${label}] Re-enqueued ${reenqueued} active run(s) on startup`
+ `[workflow] Re-enqueued ${reenqueued} active run(s) on startup`
);
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1b296d155a..b1a83f0308 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -375,6 +375,9 @@ importers:
'@workflow/builders':
specifier: workspace:*
version: link:../builders
+ '@workflow/config':
+ specifier: workspace:*
+ version: link:../config
'@workflow/rollup':
specifier: workspace:*
version: link:../rollup
@@ -406,6 +409,9 @@ importers:
'@swc/core':
specifier: 'catalog:'
version: 1.15.3
+ '@workflow/config':
+ specifier: workspace:*
+ version: link:../config
'@workflow/core':
specifier: workspace:*
version: link:../core
@@ -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
@@ -760,6 +797,9 @@ importers:
'@workflow/builders':
specifier: workspace:*
version: link:../builders
+ '@workflow/config':
+ specifier: workspace:*
+ version: link:../config
'@workflow/core':
specifier: workspace:*
version: link:../core
@@ -800,6 +840,9 @@ importers:
'@workflow/builders':
specifier: workspace:*
version: link:../builders
+ '@workflow/config':
+ specifier: workspace:*
+ version: link:../config
'@workflow/core':
specifier: workspace:*
version: link:../core
@@ -902,6 +945,9 @@ importers:
'@workflow/builders':
specifier: workspace:*
version: link:../builders
+ '@workflow/config':
+ specifier: workspace:*
+ version: link:../config
'@workflow/rollup':
specifier: workspace:*
version: link:../rollup
@@ -1282,6 +1328,9 @@ importers:
'@workflow/cli':
specifier: workspace:*
version: link:../cli
+ '@workflow/config':
+ specifier: workspace:*
+ version: link:../config
'@workflow/core':
specifier: workspace:*
version: link:../core
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}`);