Files
vercel__workflow/workbench/swc-playground/components/swc-playground.tsx
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

533 lines
19 KiB
TypeScript

'use client';
import type { Monaco } from '@monaco-editor/react';
import {
AlertCircle,
CheckCircle2,
ChevronDownIcon,
Loader2,
RotateCcw,
XCircle,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import { Switch } from '@/components/ui/switch';
import { nodeTypeDeclarations, typeDeclarations } from '@/lib/generated-types';
import {
analyzeSerdeFromTransformOutput,
initWasm,
type SerdeAnalysis,
transformCode,
} from '@/lib/transform';
import { CodeEditor } from './editor';
const STORAGE_KEY = 'swc-playground-code';
const MODULE_SPECIFIER_STORAGE_KEY = 'swc-playground-module-specifier';
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 processDocument(doc: Document) {
"use step";
return doc.content.length;
}
export async function main(filePath: string) {
"use workflow";
const doc = new Document(filePath, '');
const loaded = await doc.load();
await sleep(1000);
const length = await processDocument(loaded);
return length;
}
`.trim();
function getStoredCode(): string {
if (typeof window === 'undefined') return DEFAULT_CODE;
return localStorage.getItem(STORAGE_KEY) || DEFAULT_CODE;
}
function getStoredModuleSpecifier(): string {
if (typeof window === 'undefined') return '';
return localStorage.getItem(MODULE_SPECIFIER_STORAGE_KEY) || '';
}
function getStoredVimMode(): boolean {
if (typeof window === 'undefined') return false;
return localStorage.getItem(VIM_MODE_STORAGE_KEY) === 'true';
}
type ViewMode = 'workflow' | 'step';
type PanelId = ViewMode | 'serde';
interface CompilationResult {
code: string;
error?: string;
}
interface SwcPlaygroundProps {
pluginVersion?: string;
gitCommitSha?: string;
}
export function SwcPlayground({
pluginVersion,
gitCommitSha,
}: SwcPlaygroundProps) {
const [code, setCode] = useState(DEFAULT_CODE);
const [moduleSpecifier, setModuleSpecifier] = useState('');
const [vimMode, setVimMode] = useState(false);
const [isHydrated, setIsHydrated] = useState(false);
const [wasmReady, setWasmReady] = useState(false);
const [wasmError, setWasmError] = useState<string | null>(null);
const [results, setResults] = useState<Record<ViewMode, CompilationResult>>({
workflow: { code: '' },
step: { code: '' },
});
const [isCompiling, setIsCompiling] = useState(false);
const [serdeAnalysis, setSerdeAnalysis] = useState<SerdeAnalysis | null>(
null
);
const [expandedPanels, setExpandedPanels] = useState<Set<PanelId>>(
new Set(['workflow', 'step', 'serde'])
);
const monacoConfigured = useRef(false);
// Configure Monaco TypeScript language service with workflow type definitions
const configureMonaco = useCallback((monaco: Monaco) => {
if (monacoConfigured.current) return;
monacoConfigured.current = true;
const ts = monaco.languages.typescript;
// Configure TypeScript compiler options for the editor
ts.typescriptDefaults.setCompilerOptions({
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.NodeJs,
allowNonTsExtensions: true,
strict: true,
jsx: ts.JsxEmit.ReactJSX,
esModuleInterop: true,
allowImportingTsExtensions: true,
});
// Register ambient module declarations for workflow packages.
// This is a single string containing `declare module "..."` blocks
// with inlined type content for each package.
ts.typescriptDefaults.addExtraLib(typeDeclarations);
// Register @types/node declarations for Node.js built-in modules
for (const [path, content] of Object.entries(nodeTypeDeclarations)) {
ts.typescriptDefaults.addExtraLib(content, path);
}
}, []);
// Initialize WASM module on mount
useEffect(() => {
initWasm()
.then(() => setWasmReady(true))
.catch((err) => {
console.error('Failed to initialize WASM:', err);
setWasmError(
err instanceof Error ? err.message : 'Failed to load WASM module'
);
});
}, []);
// Hydrate from localStorage on mount
useEffect(() => {
const stored = getStoredCode();
setCode(stored);
const storedModuleSpecifier = getStoredModuleSpecifier();
setModuleSpecifier(storedModuleSpecifier);
const storedVimMode = getStoredVimMode();
setVimMode(storedVimMode);
setIsHydrated(true);
}, []);
// Save code to localStorage when it changes
useEffect(() => {
if (isHydrated) {
try {
localStorage.setItem(STORAGE_KEY, code);
} catch {
// localStorage may be disabled or full
}
}
}, [code, isHydrated]);
// Save moduleSpecifier to localStorage when it changes
useEffect(() => {
if (isHydrated) {
try {
localStorage.setItem(MODULE_SPECIFIER_STORAGE_KEY, moduleSpecifier);
} catch {
// localStorage may be disabled or full
}
}
}, [moduleSpecifier, isHydrated]);
// Save vim mode to localStorage when it changes
useEffect(() => {
if (isHydrated) {
try {
localStorage.setItem(VIM_MODE_STORAGE_KEY, String(vimMode));
} catch {
// localStorage may be disabled or full
}
}
}, [vimMode, isHydrated]);
const compile = useCallback(
async (sourceCode: string) => {
if (!wasmReady) return;
setIsCompiling(true);
try {
const transformResults = await transformCode(
sourceCode,
moduleSpecifier || undefined
);
setResults(transformResults);
// Run serde analysis on workflow output
if (
transformResults.workflow.code &&
!transformResults.workflow.error
) {
setSerdeAnalysis(
analyzeSerdeFromTransformOutput(
sourceCode,
transformResults.workflow.code
)
);
} else {
setSerdeAnalysis(null);
}
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Transform error';
setResults({
workflow: { code: '', error: errorMessage },
step: { code: '', error: errorMessage },
});
setSerdeAnalysis(null);
} finally {
setIsCompiling(false);
}
},
[moduleSpecifier, wasmReady]
);
useEffect(() => {
if (!wasmReady) return;
const timer = setTimeout(() => {
compile(code);
}, 300);
return () => clearTimeout(timer);
}, [code, compile, wasmReady]);
const togglePanel = (mode: PanelId) => {
setExpandedPanels((prev) => {
const next = new Set(prev);
if (next.has(mode)) {
next.delete(mode);
} else {
next.add(mode);
}
return next;
});
};
return (
<div className="flex flex-col h-screen bg-background text-foreground">
<header className="flex items-center justify-between px-6 py-3 border-b bg-card">
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold">
Workflow SDK Compiler Playground
</h1>
<span className="text-xs px-2 py-1 bg-muted rounded text-muted-foreground">
@workflow/swc-plugin{pluginVersion ? `@${pluginVersion}` : ''}
</span>
{!wasmReady && !wasmError && (
<span className="text-xs px-2 py-1 bg-muted rounded text-muted-foreground flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Loading WASM...
</span>
)}
{wasmError && (
<span className="text-xs px-2 py-1 bg-red-100 dark:bg-red-900/30 rounded text-red-600 dark:text-red-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
WASM failed to load
</span>
)}
{gitCommitSha && (
<a
href={`https://github.com/vercel/workflow/commit/${gitCommitSha}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs px-2 py-1 bg-muted rounded text-muted-foreground hover:text-foreground transition-colors"
>
{gitCommitSha.slice(0, 7)}
</a>
)}
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label htmlFor="vim-mode" className="text-sm text-muted-foreground">
Vim
</label>
<Switch
id="vim-mode"
size="sm"
checked={vimMode}
onCheckedChange={setVimMode}
/>
</div>
<div className="flex items-center gap-2">
<label
htmlFor="module-specifier"
className="text-sm text-muted-foreground"
>
Module Specifier:
</label>
<input
id="module-specifier"
type="text"
value={moduleSpecifier}
onChange={(e) => setModuleSpecifier(e.target.value)}
placeholder="e.g., my-package@1.0.0"
className="text-sm px-3 py-1 bg-muted rounded border border-input focus:outline-none focus:ring-2 focus:ring-ring w-64"
/>
</div>
</div>
</header>
<main className="flex-1 overflow-hidden">
<ResizablePanelGroup direction="horizontal">
{/* Input Section */}
<ResizablePanel defaultSize={50} minSize={25}>
<div className="h-full flex flex-col">
<div className="bg-muted px-4 py-2 text-sm font-medium border-b flex items-center justify-between shrink-0">
<span>Input (TypeScript)</span>
<button
type="button"
onClick={() => setCode(DEFAULT_CODE)}
disabled={isHydrated && code === DEFAULT_CODE}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-muted-foreground"
title="Reset to default code"
>
<RotateCcw className="w-3 h-3" />
Reset
</button>
</div>
<div className="flex-1 min-h-0">
<CodeEditor
language="typescript"
value={code}
onChange={(val) => setCode(val || '')}
vimMode={vimMode}
onMount={(_editor, monaco) => configureMonaco(monaco)}
/>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* Output Section - Collapsible Panels */}
<ResizablePanel defaultSize={50} minSize={25}>
<div className="h-full flex flex-col overflow-hidden bg-muted/10">
{(['workflow', 'step'] as const).map((mode) => {
const isOpen = expandedPanels.has(mode);
return (
<div
key={mode}
className={`flex flex-col min-h-0 border-b last:border-b-0 ${
isOpen ? 'flex-1' : ''
}`}
>
<button
type="button"
onClick={() => togglePanel(mode)}
aria-expanded={isOpen}
aria-controls={`output-panel-${mode}`}
className="bg-muted px-4 py-2 text-sm font-medium border-b flex items-center justify-between shrink-0 hover:bg-muted/80 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2">
<ChevronDownIcon
className={`size-4 shrink-0 text-muted-foreground transition-transform duration-200 ${
isOpen ? '' : '-rotate-90'
}`}
/>
<span className="capitalize">{mode} Output</span>
{isCompiling && (
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
)}
</div>
</button>
{isOpen && (
<div
id={`output-panel-${mode}`}
className="flex-1 min-h-0 relative"
>
{results[mode].error ? (
<div className="absolute inset-0 p-4 text-red-500 font-mono text-sm overflow-auto bg-red-50/10">
<div className="flex items-center gap-2 mb-2 font-bold">
<AlertCircle className="w-4 h-4" />
Compilation Error
</div>
{results[mode].error}
</div>
) : (
<CodeEditor
language="javascript"
value={results[mode].code}
options={{ readOnly: true }}
/>
)}
</div>
)}
</div>
);
})}
{/* Serde Analysis Panel */}
{serdeAnalysis &&
(() => {
const isSerdeOpen = expandedPanels.has('serde');
const allCompliant = serdeAnalysis.classes.every(
(c) => c.compliant
);
return (
<div
className={`flex flex-col min-h-0 border-b last:border-b-0 ${
isSerdeOpen ? 'flex-1' : ''
}`}
>
<button
type="button"
onClick={() => togglePanel('serde')}
aria-expanded={isSerdeOpen}
aria-controls="output-panel-serde"
className="bg-muted px-4 py-2 text-sm font-medium border-b flex items-center justify-between shrink-0 hover:bg-muted/80 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2">
<ChevronDownIcon
className={`size-4 shrink-0 text-muted-foreground transition-transform duration-200 ${
isSerdeOpen ? '' : '-rotate-90'
}`}
/>
<span>Serde Analysis</span>
{allCompliant ? (
<CheckCircle2 className="w-3.5 h-3.5 text-green-500" />
) : (
<XCircle className="w-3.5 h-3.5 text-red-500" />
)}
</div>
</button>
{isSerdeOpen && (
<div
id="output-panel-serde"
className="flex-1 min-h-0 overflow-auto p-4 text-sm"
>
{serdeAnalysis.classes.map((cls) => (
<div
key={cls.classId || cls.className}
className="mb-3 last:mb-0"
>
<div className="flex items-center gap-2 mb-1">
{cls.compliant ? (
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0" />
) : (
<XCircle className="w-4 h-4 text-red-500 shrink-0" />
)}
<span className="font-medium">
{cls.className}
</span>
<span
className={
cls.compliant
? 'text-green-500'
: 'text-red-500'
}
>
{cls.compliant
? 'Compliant'
: 'Not Compliant'}
</span>
</div>
<div className="ml-6 text-muted-foreground space-y-0.5">
{cls.classId && (
<div>
classId:{' '}
<code className="text-xs bg-muted px-1 rounded">
{cls.classId}
</code>
</div>
)}
<div>
Detected by SWC: {cls.detected ? 'yes' : 'no'}
</div>
<div>
Registration IIFE:{' '}
{cls.registered ? 'yes' : 'no'}
</div>
{cls.nodeImports.length > 0 && (
<div className="text-yellow-500">
Node.js imports in workflow bundle:{' '}
{cls.nodeImports.join(', ')}
</div>
)}
{cls.issues.map((issue, i) => (
<div key={i} className="text-yellow-500">
{issue}
</div>
))}
</div>
</div>
))}
{serdeAnalysis.globalNodeImports.length > 0 && (
<div className="mt-2 pt-2 border-t text-yellow-500">
All Node.js imports in workflow output:{' '}
{serdeAnalysis.globalNodeImports.join(', ')}
</div>
)}
</div>
)}
</div>
);
})()}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</main>
</div>
);
}