Skip to content

Instantly share code, notes, and snippets.

@eersnington
Created November 19, 2025 18:30
Show Gist options
  • Select an option

  • Save eersnington/32a955e544ac015127693ea864b5d8c1 to your computer and use it in GitHub Desktop.

Select an option

Save eersnington/32a955e544ac015127693ea864b5d8c1 to your computer and use it in GitHub Desktop.
PR Proposal: Production-Ready Client Bundle Generation

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.

Key Features Added

  1. Automatic Type Definitions (client.d.ts): Generates a TypeScript declaration file alongside the bundle, re-exporting original types.
  2. Node16/ESM Compatibility: Automatically appends .js extensions to relative imports in .d.ts files, ensuring compatibility with moduleResolution: "Node16" or "NodeNext".
  3. Watch Mode Support: Refactored to use esbuild.context to support incremental rebuilds. When workflow dev is running (or the builder is invoked in watch mode), changes to workflow code are instantly reflected in the client bundle.
  4. Source Maps: Enabled source maps (inline for watch mode, standard for build) to ensure stack traces and debuggers point to the original source code, not the generated bundle.
  5. Standard Configuration: Updates default configuration to enable client bundle generation by default at .well-known/workflow/v1/client.js.
  6. Integrated Express Dev Mode: Updated workflow-express middleware to automatically run the builder in watch mode when dev: true (or implicitly in development environments), eliminating the need for a separate workflow build --watch process.

The Problem

Previously, createClientLibrary was a "Work in Progress" that:

  • Generated untyped JavaScript, leading to any types 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.ts files incompatible with modern TypeScript ESM resolution (missing .js extensions).
  • 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.

The Solution

1. Robust Type Generation

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.

Example Generated Output

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';

2. Persistent Watch Context

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.

3. Debuggability

We enabled source maps based on the environment:

  • Dev (Watch): sourcemap: 'inline' for fast, self-contained stack traces.
  • Prod (Build): sourcemap: true for separate map files.

4. Integrated Builder (Express)

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.

Code Changes Required

packages/builders/src/base-builder.ts

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

packages/cli/src/lib/config/workflow-config.ts

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

packages/express/src/middleware.ts (and related files)

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 ...
}

Future Enablement: The "Native Import" DX

This change unlocks a superior Developer Experience for users of frameworks like Express, Fastify, or Hono.

Before (Legacy State)

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'); // Untyped

After (Enabled by this PR)

Simple 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
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment