Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 5 additions & 0 deletions .changeset/smart-tables-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/builders": patch
---

Bundle serde only files
26 changes: 24 additions & 2 deletions packages/builders/src/apply-swc-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,23 @@ export type WorkflowManifest = {
};
};

export interface SwcTransformOptions {
/**
* When the file comes from a package in node_modules, this is the package path
* relative to the project root (e.g., "node_modules/just-bash").
*
* Used to generate stable IDs (workflow, step, class) that work across different
* export conditions. Different export conditions resolve to different files, but
* should produce the same IDs for serialization compatibility.
*/
packagePath?: string;
}

export async function applySwcTransform(
filename: string,
source: string,
mode: 'workflow' | 'step' | 'client' | false
mode: 'workflow' | 'step' | 'client' | false,
options?: SwcTransformOptions
): Promise<{
code: string;
workflowManifest: WorkflowManifest;
Expand All @@ -67,6 +80,15 @@ export async function applySwcTransform(
filename.endsWith('.mts') ||
filename.endsWith('.cts');

// Build plugin config - include packagePath if provided
// Note: pluginConfig is only used when mode is truthy (string), so the cast is safe
const pluginConfig: { mode: string; packagePath?: string } = {
mode: mode as string,
};
if (options?.packagePath) {
pluginConfig.packagePath = options.packagePath;
}

// Transform with SWC to support syntax esbuild doesn't
const result = await transform(source, {
filename,
Expand All @@ -88,7 +110,7 @@ export async function applySwcTransform(
target: 'es2022',
experimental: mode
? {
plugins: [[swcPluginPath, { mode }]],
plugins: [[swcPluginPath, pluginConfig]],
}
: undefined,
transform: {
Expand Down
17 changes: 14 additions & 3 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ export abstract class BaseBuilder {
sourcemap: false,
absWorkingDir: this.config.workingDir,
logLevel: 'silent',
// Use 'workflow' condition to prefer workflow-optimized entry points
// (e.g., packages that export a lightweight version for workflow VMs).
conditions: ['workflow'],
// External packages that should not be bundled during discovery
external: this.config.externalPackages || [],
});
Expand Down Expand Up @@ -398,6 +401,7 @@ export abstract class BaseBuilder {
entriesToBundle: externalizeNonSteps
? [
...stepFiles,
...serdeOnlyFiles, // Include serde files so node_modules serde classes are bundled
...(resolvedBuiltInSteps ? [resolvedBuiltInSteps] : []),
]
: undefined,
Expand Down Expand Up @@ -578,13 +582,15 @@ export abstract class BaseBuilder {
createSwcPlugin({
mode: 'workflow',
workflowManifest,
// Do NOT set entriesToBundle - the workflow VM has no require() function,
// so all dependencies must be inlined into the bundle.
}),
// This plugin must run after the swc plugin to ensure dead code elimination
// happens first, preventing false positives on Node.js imports in unused code paths
createNodeModuleErrorPlugin(),
],
// External packages that should not be bundled (e.g., server-only, client-only for Next.js)
external: this.config.externalPackages || [],
// Do NOT externalize anything in the workflow bundle - the workflow VM
// has no require() function, so all dependencies must be inlined.
});
const interimBundle = await interimBundleCtx.rebuild();

Expand Down Expand Up @@ -791,7 +797,12 @@ export const POST = workflowEntrypoint(workflowCode);`;
'.mjs',
'.cjs',
],
plugins: [createSwcPlugin({ mode: 'client' })],
plugins: [
createSwcPlugin({
mode: 'client',
// Do NOT set entriesToBundle - the client bundle should inline all dependencies
}),
],
});

this.logEsbuildMessages(clientResult, 'client library bundle');
Expand Down
83 changes: 79 additions & 4 deletions packages/builders/src/discover-entries-esbuild-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util';
import enhancedResolveOriginal from 'enhanced-resolve';
import enhancedResolveOrig from 'enhanced-resolve';
import type { Plugin } from 'esbuild';
import { applySwcTransform } from './apply-swc-transform.js';
import {
Expand All @@ -9,13 +9,67 @@ import {
isWorkflowSdkFile,
} from './transform-utils.js';

const enhancedResolve = promisify(enhancedResolveOriginal);
// Create resolver with ESM conditions to properly resolve package exports.
// The 'workflow' condition is first to prefer workflow-optimized entry points
// (e.g., packages that export a lightweight version for workflow VMs).
const enhancedResolve = promisify(
enhancedResolveOrig.create({
conditionNames: ['workflow', 'node', 'import', 'default'],
exportsFields: ['exports'],
extensions: ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'],
})
);

export const jsTsRegex = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/;

// parent -> child relationship
export const importParents = new Map<string, string>();

/**
* Maps resolved file paths to their package names.
* When a file is resolved via a bare specifier import (e.g., "just-bash/workflow"),
* we track the package name so it can be used for generating stable IDs.
*/
export const resolvedPathToPackageName = new Map<string, string>();

/**
* Set of package names that have serde patterns (custom serialization).
* These packages should be bundled (not externalized) to ensure the class
* instances used in user code are the same as the ones registered for serialization.
*/
export const packagesWithSerde = new Set<string>();

/**
* Extract the package name from a bare specifier import.
* Returns null if it's not a package import (e.g., relative path).
*
* Examples:
* - "just-bash" → "just-bash"
* - "just-bash/workflow" → "just-bash"
* - "@scope/pkg" → "@scope/pkg"
* - "@scope/pkg/subpath" → "@scope/pkg"
* - "./foo" → null
*/
function extractPackageNameFromSpecifier(specifier: string): string | null {
// Not a bare specifier
if (specifier.startsWith('.') || specifier.startsWith('/')) {
return null;
}

const parts = specifier.split('/');

// Scoped package: @scope/name or @scope/name/subpath
if (parts[0].startsWith('@')) {
if (parts.length >= 2) {
return `${parts[0]}/${parts[1]}`;
}
return null;
}

// Regular package: name or name/subpath
return parts[0];
}

// check if a parent has a child in it's import chain
// e.g. if a dependency needs to be bundled because it has
// a 'use workflow/'use step' directive in it
Expand Down Expand Up @@ -51,14 +105,28 @@ export function createDiscoverEntriesPlugin(state: {
return {
name: 'discover-entries-esbuild-plugin',
setup(build) {
build.onResolve({ filter: jsTsRegex }, async (args) => {
// Track ALL imports (not just file paths with extensions) to build
// the parent-child relationship map. This is critical for detecting
// when a package like "just-bash" re-exports code containing
// 'use step', 'use workflow', or serde patterns from internal files.
build.onResolve({ filter: /.*/ }, async (args) => {
try {
const resolved = await enhancedResolve(args.resolveDir, args.path);

if (resolved) {
importParents.set(args.importer, resolved);

// Track package name for bare specifier imports
// This allows us to generate stable IDs for files from packages
const packageName = extractPackageNameFromSpecifier(args.path);
if (packageName) {
const normalizedResolved = resolved.replace(/\\/g, '/');
resolvedPathToPackageName.set(normalizedResolved, packageName);
}
}
} catch (_) {}
} catch {
// Ignore resolution errors
}
return null;
});

Expand Down Expand Up @@ -111,6 +179,13 @@ export function createDiscoverEntriesPlugin(state: {
if (!state.discoveredSerdeFiles.includes(normalizedPath)) {
state.discoveredSerdeFiles.push(normalizedPath);
}
// Track which packages have serde patterns so we can bundle them
// instead of externalizing. This ensures the class instances used in
// user code are the same as the ones registered for serialization.
const packageName = resolvedPathToPackageName.get(normalizedPath);
if (packageName) {
packagesWithSerde.add(packageName);
}
}

const { code: transformedCode } = await applySwcTransform(
Expand Down
Loading
Loading