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}`);