diff --git a/.changeset/add-cli-transform-validate.md b/.changeset/add-cli-transform-validate.md new file mode 100644 index 0000000000..399aa4bf43 --- /dev/null +++ b/.changeset/add-cli-transform-validate.md @@ -0,0 +1,5 @@ +--- +"@workflow/cli": patch +--- + +Add `workflow transform` command for inspecting SWC transform output with optional serde compliance analysis diff --git a/.changeset/add-cli-validate-serde.md b/.changeset/add-cli-validate-serde.md new file mode 100644 index 0000000000..2ed2a679b6 --- /dev/null +++ b/.changeset/add-cli-validate-serde.md @@ -0,0 +1,5 @@ +--- +"@workflow/cli": patch +--- + +Implement `workflow validate` command with serde compliance checks diff --git a/.changeset/add-serde-compliance-checker.md b/.changeset/add-serde-compliance-checker.md new file mode 100644 index 0000000000..c7f4b789c2 --- /dev/null +++ b/.changeset/add-serde-compliance-checker.md @@ -0,0 +1,5 @@ +--- +"@workflow/builders": patch +--- + +Add serde compliance checker (`analyzeSerdeCompliance`) and build-time warnings for classes with Node.js imports in workflow bundle diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 2bc7cb0ff5..e1d18ddb3d 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -792,6 +792,31 @@ export abstract class BaseBuilder { throw new Error('No output files generated from esbuild'); } + // Serde compliance warnings: check if workflow bundle has Node.js imports + // alongside serde-registered classes (these will fail at runtime in the sandbox) + if ( + workflowManifest.classes && + Object.keys(workflowManifest.classes).length > 0 + ) { + const { analyzeSerdeCompliance } = await import('./serde-checker.js'); + const bundleText = interimBundle.outputFiles[0].text; + const serdeResult = analyzeSerdeCompliance({ + sourceCode: '', + workflowCode: bundleText, + manifest: workflowManifest, + }); + for (const cls of serdeResult.classes) { + if (!cls.compliant) { + for (const issue of cls.issues) { + console.warn( + chalk.yellow(`⚠ Serde warning for class "${cls.className}": `) + + issue + ); + } + } + } + } + const bundleFinal = async (interimBundle: string) => { const workflowBundleCode = interimBundle; diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 2d3304d468..ced8243f9b 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -24,6 +24,12 @@ export { PSEUDO_PACKAGES, } from './pseudo-package-esbuild-plugin.js'; export { NORMALIZE_REQUEST_CODE } from './request-converter.js'; +export { + analyzeSerdeCompliance, + extractClassEntries, + type SerdeCheckResult, + type SerdeClassCheckResult, +} from './serde-checker.js'; export { StandaloneBuilder } from './standalone.js'; export { createSwcPlugin } from './swc-esbuild-plugin.js'; export { @@ -40,7 +46,6 @@ export { workflowSerdeImportPattern, workflowSerdeSymbolPattern, } from './transform-utils.js'; -export { resolveWorkflowAliasRelativePath } from './workflow-alias.js'; export type { AstroConfig, BuildTarget, @@ -52,3 +57,4 @@ export type { } from './types.js'; export { isValidBuildTarget, validBuildTargets } from './types.js'; export { VercelBuildOutputAPIBuilder } from './vercel-build-output-api.js'; +export { resolveWorkflowAliasRelativePath } from './workflow-alias.js'; diff --git a/packages/builders/src/serde-checker.ts b/packages/builders/src/serde-checker.ts new file mode 100644 index 0000000000..edd344954f --- /dev/null +++ b/packages/builders/src/serde-checker.ts @@ -0,0 +1,200 @@ +/** + * Serde compliance checker for workflow custom class serialization. + * + * Analyzes source code to determine if classes with WORKFLOW_SERIALIZE / + * WORKFLOW_DESERIALIZE are correctly set up for the workflow sandbox. + * + * Used by: + * - CLI `validate` command + * - CLI `transform` command (--check-serde) + * - SWC playground serde analysis panel + * - Build-time warnings in BaseBuilder + */ + +import builtinModules from 'builtin-modules'; +import type { WorkflowManifest } from './apply-swc-transform.js'; + +// Build a regex that matches Node.js built-in module imports in transformed code. +// Handles both ESM (`from 'fs'`, `from 'node:fs'`) and CJS (`require('fs')`) +const nodeBuiltins = builtinModules.join('|'); + +// Regex to extract specific module names from import/require statements +const nodeImportExtractRegex = new RegExp( + `(?:from\\s+['"](?:node:)?((?:${nodeBuiltins})(?:/[^'"]*)?)['"]` + + `|require\\s*\\(\\s*['"](?:node:)?((?:${nodeBuiltins})(?:/[^'"]*)?)['"]\\s*\\))`, + 'g' +); + +// Regex to detect class registration IIFEs generated by the SWC plugin +const registrationIifeRegex = + /Symbol\.for\s*\(\s*["']workflow-class-registry["']\s*\)/; + +/** + * Result of checking a single class for serde compliance. + */ +export interface SerdeClassCheckResult { + /** The class name as detected in the source */ + className: string; + /** The classId assigned by the SWC plugin (from the manifest) */ + classId: string; + /** Whether the SWC plugin detected serde symbols on this class */ + detected: boolean; + /** Whether a registration IIFE was generated in the output */ + registered: boolean; + /** + * Node.js built-in module imports remaining in the workflow-mode output. + * If non-empty, the class is NOT workflow-sandbox compliant. + */ + nodeImports: string[]; + /** Whether the class passes all compliance checks */ + compliant: boolean; + /** Human-readable descriptions of any issues found */ + issues: string[]; +} + +/** + * Full result of serde compliance analysis for a source file. + */ +export interface SerdeCheckResult { + /** Per-class analysis results */ + classes: SerdeClassCheckResult[]; + /** All Node.js built-in imports found in the workflow-mode output */ + globalNodeImports: string[]; + /** Whether the workflow-mode output contains any serde-related classes */ + hasSerdeClasses: boolean; + /** The raw workflow manifest extracted from the SWC transform */ + manifest: WorkflowManifest; +} + +/** + * Lightweight serde compliance checker that works with pre-computed + * SWC transform results. This avoids re-running the SWC transform + * when the caller already has the outputs (e.g., the playground or builder). + */ +export function analyzeSerdeCompliance(options: { + /** Source code (used for pattern detection) */ + sourceCode: string; + /** Workflow-mode transformed output */ + workflowCode: string; + /** Manifest extracted from the SWC transform */ + manifest: WorkflowManifest; +}): SerdeCheckResult { + const { sourceCode, workflowCode, manifest } = options; + + // 1. Extract all Node.js built-in imports from the workflow output + const globalNodeImports = extractNodeImports(workflowCode); + + // 2. Check if the manifest contains any serde-registered classes + const classEntries = extractClassEntries(manifest); + const hasSerdeClasses = classEntries.length > 0; + + // 3. Check if the workflow output contains registration IIFEs + const hasRegistration = registrationIifeRegex.test(workflowCode); + + // 4. Analyze each class + const classes: SerdeClassCheckResult[] = classEntries.map((entry) => { + const issues: string[] = []; + + // Check for Node.js imports (these will fail in the workflow sandbox) + if (globalNodeImports.length > 0) { + issues.push( + `Workflow bundle contains Node.js built-in imports: ${globalNodeImports.join(', ')}. ` + + `These will fail at runtime in the workflow sandbox. ` + + `Add "use step" to methods that depend on Node.js APIs so they are stripped from the workflow bundle.` + ); + } + + // Check for registration + 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: static [WORKFLOW_SERIALIZE](...) { ... }` + ); + } + + return { + className: entry.className, + classId: entry.classId, + detected: true, + registered: hasRegistration, + nodeImports: globalNodeImports, + compliant: globalNodeImports.length === 0 && hasRegistration, + issues, + }; + }); + + // 5. Check for classes that have serde patterns in source but weren't detected 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: '', + 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, ` + + `not assigned externally (e.g., (MyClass as any)[WORKFLOW_SERIALIZE] = ...).`, + ], + }); + } + + return { + classes, + globalNodeImports, + hasSerdeClasses, + manifest, + }; +} + +/** + * Extract Node.js built-in module names from transformed code. + */ +function extractNodeImports(code: string): string[] { + const imports = new Set(); + // Reset regex state + nodeImportExtractRegex.lastIndex = 0; + for ( + let match = nodeImportExtractRegex.exec(code); + match !== null; + match = nodeImportExtractRegex.exec(code) + ) { + // match[1] is from the ESM pattern, match[2] is from the CJS pattern + const moduleName = match[1] || match[2]; + if (moduleName) { + // Normalize to base module name (e.g., 'fs/promises' -> 'fs') + imports.add(moduleName.split('/')[0]); + } + } + return [...imports].sort(); +} + +/** + * Extract class entries from a WorkflowManifest. + */ +export function extractClassEntries( + manifest: WorkflowManifest +): Array<{ className: string; classId: string; fileName: string }> { + const entries: Array<{ + className: string; + classId: string; + fileName: string; + }> = []; + if (!manifest.classes) return entries; + + for (const [fileName, classes] of Object.entries(manifest.classes)) { + for (const [className, { classId }] of Object.entries(classes)) { + entries.push({ className, classId, fileName }); + } + } + return entries; +} diff --git a/packages/cli/src/commands/transform.ts b/packages/cli/src/commands/transform.ts new file mode 100644 index 0000000000..f74caba3be --- /dev/null +++ b/packages/cli/src/commands/transform.ts @@ -0,0 +1,234 @@ +import { readFile } from 'node:fs/promises'; +import { relative, resolve } from 'node:path'; +import { Args, Flags } from '@oclif/core'; +import { + analyzeSerdeCompliance, + applySwcTransform, + detectWorkflowPatterns, +} from '@workflow/builders'; +import chalk from 'chalk'; +import { BaseCommand } from '../base.js'; + +type TransformMode = 'workflow' | 'step' | 'client'; + +const ALL_MODES: TransformMode[] = ['workflow', 'step', 'client']; + +export default class Transform extends BaseCommand { + static description = + 'Show SWC transform output for a workflow file. Useful for inspecting what code runs in each execution context.'; + + static examples = [ + '$ workflow transform src/MyClass.ts', + '$ workflow transform src/MyClass.ts --mode workflow', + '$ workflow transform src/MyClass.ts --mode step', + '$ workflow transform src/MyClass.ts --check-serde', + '$ workflow transform src/MyClass.ts --json', + ]; + + static args = { + file: Args.string({ + description: 'The file to transform', + required: true, + }), + }; + + static flags = { + mode: Flags.string({ + char: 'm', + description: 'Transform mode (workflow, step, client, or all)', + default: 'all', + options: ['workflow', 'step', 'client', 'all'], + }), + 'check-serde': Flags.boolean({ + description: 'Run serde compliance analysis on the transformed output', + default: false, + }), + 'module-specifier': Flags.string({ + description: + 'Module specifier for the file (e.g. "my-package@1.0.0"). Auto-detected if not provided.', + }), + }; + + public async run(): Promise> { + const { args, flags } = await this.parse(Transform); + const filePath = resolve(args.file); + const relPath = relative(process.cwd(), filePath); + + let source: string; + try { + source = await readFile(filePath, 'utf-8'); + } catch { + this.error(`Could not read file: ${filePath}`); + } + + // Detect if this file has workflow patterns + const patterns = detectWorkflowPatterns(source); + if (!patterns.hasDirective && !patterns.hasSerde) { + this.logInfo( + chalk.yellow( + `Warning: ${relPath} does not contain "use workflow", "use step", or serde patterns.` + ) + ); + } + + const modes: TransformMode[] = + flags.mode === 'all' ? ALL_MODES : [flags.mode as TransformMode]; + + const results: Record = {}; + + for (const mode of modes) { + try { + const { code } = await applySwcTransform( + relPath, + source, + mode, + filePath + ); + results[mode] = { code }; + } catch (err) { + results[mode] = { + code: '', + error: err instanceof Error ? err.message : 'Transform failed', + }; + } + } + + // Serde analysis + let serdeAnalysis = null; + if (flags['check-serde'] || patterns.hasSerde) { + // Ensure we have workflow mode output for serde analysis + let workflowCode = results.workflow?.code; + let workflowManifest = null; + + if (!workflowCode) { + try { + const result = await applySwcTransform( + relPath, + source, + 'workflow', + filePath + ); + workflowCode = result.code; + workflowManifest = result.workflowManifest; + } catch { + workflowCode = ''; + } + } else { + // Re-run to get the manifest + try { + const result = await applySwcTransform( + relPath, + source, + 'workflow', + filePath + ); + workflowManifest = result.workflowManifest; + } catch { + // ignore + } + } + + if (workflowCode && workflowManifest) { + serdeAnalysis = analyzeSerdeCompliance({ + sourceCode: source, + workflowCode, + manifest: workflowManifest, + }); + } + } + + // JSON output + if (flags.json) { + return { + file: relPath, + patterns: { + hasUseWorkflow: patterns.hasUseWorkflow, + hasUseStep: patterns.hasUseStep, + hasSerde: patterns.hasSerde, + }, + transforms: results, + ...(serdeAnalysis + ? { + serdeAnalysis: { + hasSerdeClasses: serdeAnalysis.hasSerdeClasses, + globalNodeImports: serdeAnalysis.globalNodeImports, + classes: serdeAnalysis.classes, + }, + } + : {}), + }; + } + + // Human-readable output + for (const mode of modes) { + const result = results[mode]; + this.logInfo(''); + this.logInfo(chalk.bold.cyan(`═══ ${mode.toUpperCase()} MODE ═══`)); + if (result.error) { + this.logInfo(chalk.red(`Error: ${result.error}`)); + } else { + this.logInfo(result.code); + } + } + + // Serde analysis output + if (serdeAnalysis) { + this.logInfo(''); + this.logInfo(chalk.bold.cyan('═══ SERDE ANALYSIS ═══')); + + if ( + !serdeAnalysis.hasSerdeClasses && + serdeAnalysis.classes.length === 0 + ) { + this.logInfo(chalk.gray('No serde-enabled classes detected.')); + } else { + for (const cls of serdeAnalysis.classes) { + this.logInfo(''); + this.logInfo( + cls.compliant + ? chalk.green(`✓ Class "${cls.className}" is serde-compliant`) + : chalk.red(`✗ Class "${cls.className}" is NOT serde-compliant`) + ); + + if (cls.classId) { + this.logInfo(chalk.gray(` classId: ${cls.classId}`)); + } + this.logInfo( + ` Detected by SWC: ${cls.detected ? chalk.green('yes') : chalk.red('no')}` + ); + this.logInfo( + ` Registration IIFE: ${cls.registered ? chalk.green('yes') : chalk.red('no')}` + ); + + if (cls.nodeImports.length > 0) { + this.logInfo( + chalk.yellow( + ` Node.js imports in workflow bundle: ${cls.nodeImports.join(', ')}` + ) + ); + } + + for (const issue of cls.issues) { + this.logInfo(chalk.yellow(` ⚠ ${issue}`)); + } + } + + if (serdeAnalysis.globalNodeImports.length > 0) { + this.logInfo(''); + this.logInfo( + chalk.yellow( + `Node.js built-in imports found in workflow output: ${serdeAnalysis.globalNodeImports.join(', ')}` + ) + ); + this.logInfo( + chalk.yellow( + 'Add "use step" to methods that depend on these modules.' + ) + ); + } + } + } + + return {}; + } +} diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 2fb3b38214..ee06179b4f 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -1,55 +1,218 @@ +import { readFile } from 'node:fs/promises'; +import { relative, resolve } from 'node:path'; import { Flags } from '@oclif/core'; +import { + analyzeSerdeCompliance, + applySwcTransform, + detectWorkflowPatterns, + type SerdeClassCheckResult, +} from '@workflow/builders'; +import chalk from 'chalk'; +import { glob } from 'tinyglobby'; import { BaseCommand } from '../base.js'; +interface FileValidationResult { + file: string; + hasUseWorkflow: boolean; + hasUseStep: boolean; + hasSerde: boolean; + serdeClasses: SerdeClassCheckResult[]; + errors: string[]; +} + export default class Validate extends BaseCommand { - static description = 'Validate workflow files (Coming soon)'; + static description = + 'Validate workflow files for correctness, including serde compliance checks'; static examples = [ '$ workflow validate', - '$ workflow validate --fix', + '$ workflow validate --dir src/workflows', '$ workflow validate --strict', + '$ workflow validate --json', ]; static flags = { - fix: Flags.boolean({ - description: 'automatically fix fixable issues', - }), strict: Flags.boolean({ - description: 'enable strict validation mode', + description: 'exit with code 1 if any validation issues are found', }), dir: Flags.string({ char: 'd', description: 'directory to validate', - default: './workflows', + default: '.', }), }; - public async run(): Promise { + public async run(): Promise> { const { flags } = await this.parse(Validate); + const targetDir = resolve(flags.dir); - this.logInfo('🚧 Workflow validation is coming soon!'); - this.logInfo(''); - this.logInfo('This command will provide:'); - this.logInfo('• Syntax validation for workflow files'); - this.logInfo('• Type checking for steps and workflows'); - this.logInfo('• Dependency analysis'); - this.logInfo('• Best practice recommendations'); - this.logInfo('• Auto-fixing for common issues'); - this.logInfo(''); + // Find all TS/JS files in the target directory + const files = await glob( + ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.mts', '**/*.cts'], + { + cwd: targetDir, + ignore: [ + '**/node_modules/**', + '**/dist/**', + '**/.next/**', + '**/.nuxt/**', + '**/.svelte-kit/**', + '**/.vercel/**', + '**/.well-known/**', + '**/*.test.*', + '**/*.spec.*', + '**/*.d.ts', + ], + } + ); + + const results: FileValidationResult[] = []; + let totalIssues = 0; + let filesScanned = 0; + let filesWithWorkflowPatterns = 0; + + for (const file of files) { + const absPath = resolve(targetDir, file); + const relPath = relative(process.cwd(), absPath); - this.logInfo(`Target directory: ${flags.dir}`); + let source: string; + try { + source = await readFile(absPath, 'utf-8'); + } catch { + continue; + } - if (flags.fix) { - this.logInfo('Auto-fix mode enabled'); + filesScanned++; + const patterns = detectWorkflowPatterns(source); + + if (!patterns.hasDirective && !patterns.hasSerde) { + continue; + } + + filesWithWorkflowPatterns++; + const fileResult: FileValidationResult = { + file: relPath, + hasUseWorkflow: patterns.hasUseWorkflow, + hasUseStep: patterns.hasUseStep, + hasSerde: patterns.hasSerde, + serdeClasses: [], + errors: [], + }; + + // Run SWC transform in workflow mode to check serde compliance + if (patterns.hasSerde) { + try { + const { code, workflowManifest } = await applySwcTransform( + relPath, + source, + 'workflow', + absPath + ); + + const serdeResult = analyzeSerdeCompliance({ + sourceCode: source, + workflowCode: code, + manifest: workflowManifest, + }); + + fileResult.serdeClasses = serdeResult.classes; + + for (const cls of serdeResult.classes) { + if (!cls.compliant) { + totalIssues += cls.issues.length; + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Transform failed'; + fileResult.errors.push(`SWC transform failed: ${msg}`); + totalIssues++; + } + } + + // Only include files with issues or serde classes in results + if (fileResult.serdeClasses.length > 0 || fileResult.errors.length > 0) { + results.push(fileResult); + } } - if (flags.strict) { - this.logInfo('Strict validation mode enabled'); + // JSON output + if (flags.json) { + const output = { + filesScanned, + filesWithWorkflowPatterns, + totalIssues, + results: results.map((r) => ({ + file: r.file, + hasUseWorkflow: r.hasUseWorkflow, + hasUseStep: r.hasUseStep, + hasSerde: r.hasSerde, + serdeClasses: r.serdeClasses, + errors: r.errors, + })), + }; + + if (flags.strict && totalIssues > 0) { + this.exit(1); + } + + return output; } - this.logInfo(''); + // Human-readable output this.logInfo( - 'For now, use TypeScript compiler to check your workflow files' + chalk.bold( + `Scanned ${filesScanned} files, ${filesWithWorkflowPatterns} with workflow patterns` + ) ); + this.logInfo(''); + + if (results.length === 0) { + this.logInfo(chalk.green('No serde issues found.')); + return {}; + } + + for (const result of results) { + this.logInfo(chalk.bold(result.file)); + + // Show errors + for (const error of result.errors) { + this.logInfo(chalk.red(` ✗ ${error}`)); + } + + // Show serde class results + for (const cls of result.serdeClasses) { + if (cls.compliant) { + this.logInfo( + chalk.green(` ✓ Class "${cls.className}" is serde-compliant`) + ); + } else { + this.logInfo( + chalk.red(` ✗ Class "${cls.className}" has serde issues:`) + ); + for (const issue of cls.issues) { + this.logInfo(chalk.yellow(` ⚠ ${issue}`)); + } + } + } + + this.logInfo(''); + } + + // Summary + if (totalIssues > 0) { + this.logInfo( + chalk.red( + `Found ${totalIssues} issue${totalIssues === 1 ? '' : 's'} across ${results.length} file${results.length === 1 ? '' : 's'}.` + ) + ); + } else { + this.logInfo(chalk.green('All serde classes are compliant.')); + } + + if (flags.strict && totalIssues > 0) { + this.exit(1); + } + + return {}; } } diff --git a/skills/workflow/SKILL.md b/skills/workflow/SKILL.md index 8c7a482f75..eb50a30f37 100644 --- a/skills/workflow/SKILL.md +++ b/skills/workflow/SKILL.md @@ -3,7 +3,7 @@ name: workflow description: Creates durable, resumable workflows using Vercel's Workflow DevKit. Use when building workflows that need to survive restarts, pause for external events, retry on failure, or coordinate multi-step operations over time. Triggers on mentions of "workflow", "durable functions", "resumable", "workflow devkit", "queue", "event", "push", "subscribe", or step-based orchestration. metadata: author: Vercel Inc. - version: '1.4' + version: '1.5' --- ## *CRITICAL*: Always Use Correct `workflow` Documentation @@ -289,9 +289,66 @@ if (res.status === 429) { All data passed to/from workflows and steps must be serializable. -**Supported types:** string, number, boolean, null, undefined, bigint, plain objects, arrays, Date, RegExp, URL, URLSearchParams, Map, Set, Headers, ArrayBuffer, typed arrays, Request, Response, ReadableStream, WritableStream. +**Supported built-in types:** string, number, boolean, null, undefined, bigint, plain objects, arrays, Date, RegExp, URL, URLSearchParams, Map, Set, Headers, ArrayBuffer, typed arrays, Request, Response, ReadableStream, WritableStream. -**Not supported:** Functions, class instances, Symbols, WeakMap/WeakSet. Pass data, not callbacks. +**Not supported:** Functions, Symbols, WeakMap/WeakSet. Pass data, not callbacks. + +### Custom Class Serialization + +Class instances **can** be serialized across workflow/step boundaries by implementing the `@workflow/serde` protocol. This is essential when a class has instance methods with `"use step"` or when you want to pass class instances between steps. + +**Install:** `@workflow/serde` must be a dependency of the package containing the class. + +**Pattern:** Add two static methods inside the class body using computed property syntax: + +```typescript +import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; + +export class Point { + x: number; + y: number; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + + // Serialize: return plain data (must be devalue-compatible types only) + static [WORKFLOW_SERIALIZE](instance: Point) { + return { x: instance.x, y: instance.y }; + } + + // Deserialize: reconstruct from plain data + static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) { + return new Point(data.x, data.y); + } + + async computeDistance(other: Point) { + "use step"; + return Math.sqrt((this.x - other.x) ** 2 + (this.y - other.y) ** 2); + } +} +``` + +**Critical rules:** +1. **Define serde methods INSIDE the class body** as static methods with computed property syntax (`static [WORKFLOW_SERIALIZE](...)`). The SWC plugin detects them by scanning the class. Do NOT assign them externally (e.g., `(MyClass as any)[WORKFLOW_SERIALIZE] = ...`) -- the compiler will not detect this. +2. **Serde methods must return only devalue-compatible types** (plain objects, arrays, primitives, Date, Map, Set, Uint8Array, etc.). No functions, no class instances, no Node.js-specific objects. +3. **Add `"use step"` to Node.js-dependent instance methods.** The SWC plugin strips `"use step"` method bodies from the workflow bundle. This is how you keep Node.js imports (fs, crypto, child_process, etc.) out of the workflow sandbox. The class shell with its serde methods remains in the workflow bundle; only the step method bodies are removed. +4. **Do NOT manually register classes.** The SWC plugin automatically generates registration code (an IIFE that sets `classId` and adds the class to the global registry). Manual calls to `registerSerializationClass()` are unnecessary and error-prone. +5. **Do NOT use dynamic imports to work around sandbox restrictions.** If a class method needs Node.js APIs, the correct solution is `"use step"`, not `/* @vite-ignore */ import(...)`. + +**When serde works well:** Pure data classes, domain models, configuration objects, and classes where Node.js-dependent methods can be marked with `"use step"`. + +**When to avoid serde:** If a class is fundamentally inseparable from Node.js APIs (every method needs `fs`, `net`, etc.) and cannot meaningfully exist as a shell in the workflow sandbox, keep it entirely in step functions and pass plain data objects across boundaries instead. + +### Validating Serde Compliance + +Use these tools to verify classes are correctly set up: + +- **`workflow transform --check-serde`** -- Shows the SWC transform output for a file and checks if serde classes are compliant (no Node.js imports remaining in the workflow bundle). +- **`workflow validate`** -- Scans all workflow files and reports serde compliance issues. Use `--json` for machine-readable output. +- **SWC Playground** -- The web playground at `workbench/swc-playground` shows a Serde Analysis panel when serde patterns are detected. +- **Build-time warnings** -- The builder automatically warns when serde classes have Node.js built-in imports remaining in the workflow bundle. ## Streaming diff --git a/workbench/swc-playground/components/swc-playground.tsx b/workbench/swc-playground/components/swc-playground.tsx index a2f3b11a20..0576a4e272 100644 --- a/workbench/swc-playground/components/swc-playground.tsx +++ b/workbench/swc-playground/components/swc-playground.tsx @@ -1,6 +1,13 @@ 'use client'; -import { AlertCircle, ChevronDownIcon, Loader2, RotateCcw } from 'lucide-react'; +import { + AlertCircle, + CheckCircle2, + ChevronDownIcon, + Loader2, + RotateCcw, + XCircle, +} from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; import { ResizableHandle, @@ -8,7 +15,7 @@ import { ResizablePanelGroup, } from '@/components/ui/resizable'; import { Switch } from '@/components/ui/switch'; -import { transformCode } from '@/lib/transform-action'; +import { type SerdeAnalysis, transformCode } from '@/lib/transform-action'; import { CodeEditor } from './editor'; const STORAGE_KEY = 'swc-playground-code'; @@ -17,17 +24,39 @@ const VIM_MODE_STORAGE_KEY = 'swc-playground-vim-mode'; const DEFAULT_CODE = ` import { sleep } from 'workflow'; +import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde'; +import { readFile } from 'node:fs/promises'; + +export class Document { + constructor(public path: string, public content: string) {} + + static [WORKFLOW_SERIALIZE](instance: Document) { + return { path: instance.path, content: instance.content }; + } + + static [WORKFLOW_DESERIALIZE](data: { path: string; content: string }) { + return new Document(data.path, data.content); + } + + async load() { + "use step"; + const content = await readFile(this.path, 'utf-8'); + return new Document(this.path, content); + } +} -async function myStep(a: number) { +async function processDocument(doc: Document) { "use step"; - return a + 1; + return doc.content.length; } -export async function main() { +export async function main(filePath: string) { "use workflow"; + const doc = new Document(filePath, ''); + const loaded = await doc.load(); await sleep(1000); - await myStep(1); - return "hello world"; + const length = await processDocument(loaded); + return length; } `.trim(); @@ -72,6 +101,9 @@ export function SwcPlayground({ client: { code: '' }, }); const [isCompiling, setIsCompiling] = useState(false); + const [serdeAnalysis, setSerdeAnalysis] = useState( + null + ); const [expandedPanels, setExpandedPanels] = useState>( new Set(['workflow', 'step', 'client']) ); @@ -130,6 +162,7 @@ export function SwcPlayground({ moduleSpecifier || undefined ); setResults(transformResults); + setSerdeAnalysis(transformResults.serdeAnalysis ?? null); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Server error'; @@ -138,6 +171,7 @@ export function SwcPlayground({ step: { code: '', error: errorMessage }, client: { code: '', error: errorMessage }, }); + setSerdeAnalysis(null); } finally { setIsCompiling(false); } @@ -303,6 +337,70 @@ export function SwcPlayground({ ); })} + + {/* Serde Analysis Panel */} + {serdeAnalysis && ( +
+
+ Serde Analysis +
+ {serdeAnalysis.classes.map((cls) => ( +
+
+ {cls.compliant ? ( + + ) : ( + + )} + {cls.className} + + {cls.compliant ? 'Compliant' : 'Not Compliant'} + +
+
+ {cls.classId && ( +
+ classId:{' '} + + {cls.classId} + +
+ )} +
+ Detected by SWC: {cls.detected ? 'yes' : 'no'} +
+
+ Registration IIFE: {cls.registered ? 'yes' : 'no'} +
+ {cls.nodeImports.length > 0 && ( +
+ Node.js imports in workflow bundle:{' '} + {cls.nodeImports.join(', ')} +
+ )} + {cls.issues.map((issue, i) => ( +
+ {issue} +
+ ))} +
+
+ ))} + {serdeAnalysis.globalNodeImports.length > 0 && ( +
+ All Node.js imports in workflow output:{' '} + {serdeAnalysis.globalNodeImports.join(', ')} +
+ )} +
+ )} diff --git a/workbench/swc-playground/lib/transform-action.ts b/workbench/swc-playground/lib/transform-action.ts index f3167b375a..92f7b7e812 100644 --- a/workbench/swc-playground/lib/transform-action.ts +++ b/workbench/swc-playground/lib/transform-action.ts @@ -1,6 +1,7 @@ 'use server'; import fs from 'node:fs'; +import module from 'node:module'; import path from 'node:path'; import swc from '@swc/core'; @@ -20,10 +21,159 @@ try { ); } +// ── 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[]; +} + +// Build a set of Node.js built-in module names for detection +const nodeBuiltins = new Set( + module.builtinModules.filter((m) => !m.startsWith('_')) +); +// Also include common sub-paths +const nodeBuiltinBases = [...nodeBuiltins]; +const nodeBuiltinPattern = nodeBuiltinBases.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(); + 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(); +} + +function analyzeSerdeFromTransformOutput( + sourceCode: string, + workflowCode: string +): SerdeAnalysis { + // Extract manifest from workflow output + 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 hasSerdeClasses = classEntries.length > 0; + + 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(', ')}. ` + + `These will fail at runtime in the workflow sandbox. ` + + `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: '', + 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, ` + + `not assigned externally (e.g., (MyClass as any)[WORKFLOW_SERIALIZE] = ...).`, + ], + }); + } + + return { + hasSerdeClasses: hasSerdeClasses || classes.length > 0, + globalNodeImports, + classes, + }; +} + +// ── Transform types and main function ───────────────────────────────────── + export interface TransformResult { workflow: { code: string; error?: string }; step: { code: string; error?: string }; client: { code: string; error?: string }; + serdeAnalysis?: SerdeAnalysis; } export async function transformCode( @@ -72,5 +222,16 @@ export async function transformCode( }) ); + // Run serde analysis on the workflow output + if (results.workflow.code && !results.workflow.error) { + const analysis = analyzeSerdeFromTransformOutput( + sourceCode, + results.workflow.code + ); + if (analysis.hasSerdeClasses) { + results.serdeAnalysis = analysis; + } + } + return results; }