This PR completes the "Client Bundle" feature (client.js) in the Workflow DevKit builder. It transforms the client bundle from a one-off build artifact into a live, fully typed, and debuggable library suitable for development and production use. It also leverages this new capability to significantly simplify the developer experience for the workflow-express integration.
- Automatic Type Definitions (
client.d.ts): Generates a TypeScript declaration file alongside the bundle, re-exporting original types. - Node16/ESM Compatibility: Automatically appends
.jsextensions to relative imports in.d.tsfiles, ensuring compatibility withmoduleResolution: "Node16"or"NodeNext". - Watch Mode Support: Refactored to use
esbuild.contextto support incremental rebuilds. Whenworkflow devis running (or the builder is invoked in watch mode), changes to workflow code are instantly reflected in the client bundle. - Source Maps: Enabled source maps (
inlinefor watch mode, standard for build) to ensure stack traces and debuggers point to the original source code, not the generated bundle. - Standard Configuration: Updates default configuration to enable client bundle generation by default at
.well-known/workflow/v1/client.js. - Integrated Express Dev Mode: Updated
workflow-expressmiddleware to automatically run the builder in watch mode whendev: true(or implicitly in development environments), eliminating the need for a separateworkflow build --watchprocess.
Previously, createClientLibrary was a "Work in Progress" that:
- Generated untyped JavaScript, leading to
anytypes in user code. - Used a one-off build, requiring users to restart their server to see changes.
- Lacked source maps, making debugging of client-side integration logic difficult.
- Generated
.d.tsfiles incompatible with modern TypeScript ESM resolution (missing.jsextensions). - Was commented out in the default CLI configuration.
Additionally, Express users had to run multiple concurrent processes (tsc, node, workflow dev) to get a working development environment, which was fragile and complex to configure.
We implemented a custom esbuild plugin (dtsPlugin) that runs on every build (initial and watch updates). It iterates through input files and generates a .d.ts file with relative exports to the original source.
File: .well-known/workflow/v1/client.d.ts
// Automatically handles .js extension for Node16/NodeNext support
export * from '../../src/workflows/user-signup.js';
export * from '../../src/workflows/payment-flow.js';We switched from esbuild.build() to esbuild.context(). If config.watch is true, we return the active context, allowing the builder to keep the client bundle in sync with the user's file system.
We enabled source maps based on the environment:
- Dev (Watch):
sourcemap: 'inline'for fast, self-contained stack traces. - Prod (Build):
sourcemap: truefor separate map files.
The workflow-express middleware now accepts a dev option. When enabled, it instantiates ExpressBuilder internally and runs it in watch mode. This means the Express server itself takes responsibility for keeping workflow files up-to-date.
Refactored createClientLibrary to manage the esbuild.context lifecycle, generate types via a plugin, and enforce ESM compatibility.
protected async createClientLibrary(): Promise<esbuild.BuildContext | void> {
if (!this.config.clientBundlePath) return;
// ... directory setup ...
// 1. Plugin for Type Generation
const dtsPlugin: esbuild.Plugin = {
name: 'client-dts-generator',
setup: (build) => {
build.onEnd(async (result) => {
if (result.errors.length > 0) return;
// Logic to generate relative exports with .js extension support
const dtsContent = inputFiles.map(file => {
// ... path calculation logic ...
// For Node16/NodeNext module resolution, we append .js extension
const ext = file.split('.').pop();
if (['ts', 'tsx', 'mts', 'cts'].includes(ext || '')) {
relativePath += '.js';
}
return `export * from '${relativePath}';`
}).join('\n');
await writeFile(dtsPath, dtsContent);
});
},
};
// 2. Context with Source Maps & Plugin
const ctx = await esbuild.context({
// ... standard options ...
sourcemap: this.config.watch ? 'inline' : true, // Enabled Source Maps
plugins: [createSwcPlugin({ mode: 'client' }), dtsPlugin],
});
// 3. Watch Mode Handling
if (this.config.watch) {
await ctx.watch();
return ctx;
} else {
await ctx.rebuild();
await ctx.dispose();
}
}Uncomment the clientBundlePath configuration to enable this feature for all standard CLI builds.
export const getWorkflowConfig = (...) => {
const config: WorkflowConfig = {
// ... other paths ...
// UNCOMMENTED: Enable client library generation at standard location
clientBundlePath: './.well-known/workflow/v1/client.js',
};
return config;
};Updated the middleware to auto-start the builder in dev mode.
export function createWorkflowMiddleware(options: WorkflowOptions = {}): RequestHandler {
const isDev = options.dev ?? process.env.NODE_ENV !== 'production';
if (isDev) {
// Automatically start the builder in watch mode
const builder = new ExpressBuilder({ ...options, dev: true });
builder.build().catch(console.error);
}
// ... standard middleware logic ...
}This change unlocks a superior Developer Experience for users of frameworks like Express, Fastify, or Hono.
Complex setup requiring multiple concurrent processes and untyped helper functions.
package.json:
"scripts": {
"dev": "concurrently \"workflow dev\" \"tsc -w\" \"node --watch dist/server.js\""
}Code:
import { getWorkflow } from 'workflow-express/workflows';
const flow = await getWorkflow('signup'); // UntypedSimple setup with integrated tooling and full type safety.
package.json:
"scripts": {
"dev": "concurrently \"tsc -w\" \"node --watch dist/server.js\""
// No explicit 'workflow dev' needed!
}Code:
// ✅ Fully Typed Import via alias or relative path
import { userSignup } from '#client';
await start(userSignup, [email]); (Note: For TypeScript to resolve types correctly, update tsconfig.json paths mapping)
"paths": {
"#client": ["./.well-known/workflow/v1/client.js"] // TS resolves the adjacent .d.ts
}