Skip to content
Draft
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-cli-transform-validate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/cli": patch
---

Add `workflow transform` command for inspecting SWC transform output with optional serde compliance analysis
5 changes: 5 additions & 0 deletions .changeset/add-cli-validate-serde.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/cli": patch
---

Implement `workflow validate` command with serde compliance checks
5 changes: 5 additions & 0 deletions .changeset/add-serde-compliance-checker.md
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
8 changes: 7 additions & 1 deletion packages/builders/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -40,7 +46,6 @@ export {
workflowSerdeImportPattern,
workflowSerdeSymbolPattern,
} from './transform-utils.js';
export { resolveWorkflowAliasRelativePath } from './workflow-alias.js';
export type {
AstroConfig,
BuildTarget,
Expand All @@ -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';
200 changes: 200 additions & 0 deletions packages/builders/src/serde-checker.ts
Original file line number Diff line number Diff line change
@@ -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: '<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, ` +
`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<string>();
// 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;
}
Loading
Loading