Files
Nathan Rajlich 417c4930be refactor(swc-plugin): remove client transform mode, merge into step mode (#1686)
* refactor(swc-plugin): remove client transform mode, merge into step mode

Remove the `client` transform mode from the SWC compiler plugin. The
`client` and `step` modes were nearly identical — both preserved step
function bodies, replaced workflow bodies with throw stubs, and emitted
the same JSON manifest. Step mode now absorbs all client-mode behaviors:

- Dead code elimination (previously only workflow + client)
- Hoisted variable references for object property steps
- All integrations use mode: 'step' instead of 'client'

BREAKING CHANGE: The `client` value for the SWC plugin `mode` option is
no longer accepted. Use `step` instead.

* fix(nitro): force-inline workflow packages in dev mode for serde classId registration

In dev mode, Nitro's Rollup externalizes npm packages like @workflow/core,
so the SWC transform plugin never processes files like run.js. This means
serde classes (e.g. Run) never get the classId registration IIFE, causing
serialization failures when step functions return Run instances.

Uses a Rollup resolveId hook to force workflow SDK packages to be bundled
(non-external) while leaving all other dependencies external. This is more
targeted than noExternals=true which bundles everything and causes TDZ
errors from circular imports in packages like vue-bundle-renderer/h3.

The Nitro module now also ignores .nitro/workflow/** in watchOptions so
writing generated workflow bundles does not retrigger Nitro's own dev
bundle rebuild loop.

Also wraps dev:reload workflow rebuilds and makes LocalBuilder.build()
atomic (writes to temp files, renames on success) to avoid partial output
state during HMR.

For Nuxt, also configures Vite's ssr.noExternal to bundle workflow
packages in the SSR context.

* fix(nitro,nuxt): address review feedback on dev-mode classId fix

- nitro builders: use crypto.randomUUID() for temp file suffix instead of
  Date.now() to avoid collisions under rapid/concurrent build() calls,
  and serialize concurrent build() calls through an internal queue so
  two overlapping dev rebuilds cannot clobber each other's temp outputs.

- nitro index: use fileURLToPath() to convert file:// URLs to filesystem
  paths, which correctly handles Windows paths (file:///C:/... -> C:\...)
  and percent-decoding, instead of relying on new URL(...).pathname.

- nuxt module: normalize vite.ssr.noExternal to an array (preserving any
  existing string/RegExp/array entry) before appending workflow package
  matchers, so the force-bundle behavior is not a no-op when noExternal
  is already set to a non-array value.
2026-04-16 22:50:46 +00:00

243 lines
6.4 KiB
TypeScript

export interface TransformResult {
workflow: { code: string; error?: string };
step: { code: string; error?: string };
}
// ── Serde analysis types and helpers ──────────────────────────────────────
export interface SerdeClassAnalysis {
className: string;
classId: string;
detected: boolean;
registered: boolean;
nodeImports: string[];
compliant: boolean;
issues: string[];
}
export interface SerdeAnalysis {
hasSerdeClasses: boolean;
globalNodeImports: string[];
classes: SerdeClassAnalysis[];
}
// Node.js built-in module base names for client-side detection.
// Generated from `require('module').builtinModules` (Node 22).
// Keep in sync with Node.js releases — sub-paths like 'fs/promises'
// are matched by the regex via the `/[^'"]*` suffix.
const NODE_BUILTINS = [
'assert',
'async_hooks',
'buffer',
'child_process',
'cluster',
'console',
'constants',
'crypto',
'dgram',
'diagnostics_channel',
'dns',
'domain',
'events',
'fs',
'http',
'http2',
'https',
'inspector',
'module',
'net',
'os',
'path',
'perf_hooks',
'process',
'punycode',
'querystring',
'readline',
'repl',
'stream',
'string_decoder',
'sys',
'test',
'timers',
'tls',
'trace_events',
'tty',
'url',
'util',
'v8',
'vm',
'wasi',
'worker_threads',
'zlib',
];
const nodeBuiltinPattern = NODE_BUILTINS.join('|');
const nodeImportExtractRegex = new RegExp(
`(?:from\\s+['"](?:node:)?((?:${nodeBuiltinPattern})(?:/[^'"]*)?)['"]` +
`|require\\s*\\(\\s*['"](?:node:)?((?:${nodeBuiltinPattern})(?:/[^'"]*)?)['"]\\s*\\))`,
'g'
);
const registrationIifeRegex =
/Symbol\.for\s*\(\s*["']workflow-class-registry["']\s*\)/;
const manifestRegex = /\/\*\*__internal_workflows({[\s\S]*?})\*\//;
interface ManifestClasses {
[fileName: string]: {
[className: string]: { classId: string };
};
}
function extractNodeImports(code: string): string[] {
const imports = new Set<string>();
nodeImportExtractRegex.lastIndex = 0;
for (
let match = nodeImportExtractRegex.exec(code);
match !== null;
match = nodeImportExtractRegex.exec(code)
) {
const moduleName = match[1] || match[2];
if (moduleName) {
imports.add(moduleName.split('/')[0]);
}
}
return [...imports].sort();
}
/**
* Analyze the workflow-mode transform output for serde compliance.
* Runs entirely client-side with no Node.js dependencies.
*/
export function analyzeSerdeFromTransformOutput(
sourceCode: string,
workflowCode: string
): SerdeAnalysis | null {
const manifestMatch = workflowCode.match(manifestRegex);
const manifest = manifestMatch
? (JSON.parse(manifestMatch[1]) as { classes?: ManifestClasses })
: { classes: undefined };
const globalNodeImports = extractNodeImports(workflowCode);
const hasRegistration = registrationIifeRegex.test(workflowCode);
const classEntries: Array<{ className: string; classId: string }> = [];
if (manifest.classes) {
for (const classes of Object.values(manifest.classes)) {
for (const [className, { classId }] of Object.entries(classes)) {
classEntries.push({ className, classId });
}
}
}
const classes: SerdeClassAnalysis[] = classEntries.map((entry) => {
const issues: string[] = [];
if (globalNodeImports.length > 0) {
issues.push(
`Workflow bundle contains Node.js built-in imports: ${globalNodeImports.join(', ')}. ` +
`Add "use step" to methods that depend on Node.js APIs.`
);
}
if (!hasRegistration) {
issues.push(
`No class registration IIFE was generated. ` +
`Ensure WORKFLOW_SERIALIZE and WORKFLOW_DESERIALIZE are defined as static methods ` +
`inside the class body using computed property syntax.`
);
}
return {
className: entry.className,
classId: entry.classId,
detected: true,
registered: hasRegistration,
nodeImports: globalNodeImports,
compliant: globalNodeImports.length === 0 && hasRegistration,
issues,
};
});
// Detect serde patterns in source not picked up by SWC
const sourceHasSerdePatterns =
/\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\]/.test(sourceCode) ||
/Symbol\.for\s*\(\s*['"]workflow-(?:serialize|deserialize)['"]\s*\)/.test(
sourceCode
);
if (sourceHasSerdePatterns && classEntries.length === 0) {
classes.push({
className: '<unknown>',
classId: '',
detected: false,
registered: false,
nodeImports: globalNodeImports,
compliant: false,
issues: [
`Source code contains WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE patterns but ` +
`the SWC plugin did not detect any serde-enabled classes. ` +
`Ensure the symbols are defined as static methods INSIDE the class body.`,
],
});
}
const hasSerdeClasses = classes.length > 0;
if (!hasSerdeClasses) return null;
return { hasSerdeClasses, globalNodeImports, classes };
}
let wasmExports: {
transform: (source: string, config_json: string) => string;
transformAll: (source: string, config_json: string) => string;
} | null = null;
let initPromise: Promise<void> | null = null;
/**
* Initialize the WASM module. Safe to call multiple times —
* subsequent calls are no-ops.
*/
export async function initWasm(): Promise<void> {
if (wasmExports) return;
if (initPromise) return initPromise;
initPromise = (async () => {
// Dynamically import the wasm-bindgen glue code.
// The `/* webpackIgnore: true */` comment prevents the bundler
// from statically analyzing the import and trying to resolve
// the .wasm file reference inside the glue code.
const glue = await import(
/* webpackIgnore: true */
'/wasm/swc_playground_wasm.js'
);
await glue.default({
module_or_path: '/wasm/swc_playground_wasm_bg.wasm',
});
wasmExports = {
transform: glue.transform,
transformAll: glue.transformAll,
};
})();
return initPromise;
}
/**
* Transform source code using the workflow SWC plugin (runs in WASM).
*
* Automatically initializes the WASM module on first call.
*/
export async function transformCode(
sourceCode: string,
moduleSpecifier?: string
): Promise<TransformResult> {
await initWasm();
const config = JSON.stringify({
moduleSpecifier,
filename: 'input.ts',
});
const resultJson = wasmExports!.transformAll(sourceCode, config);
return JSON.parse(resultJson) as TransformResult;
}