Move SWC playground to client-side WASM and add Monaco type intellisense (#1553)

* Move SWC playground transform from server action to client-side WASM

Create a new swc-playground-wasm crate that bundles swc_ecma_parser,
swc_ecma_codegen, and the swc_workflow transform visitor into a single
WASM binary via wasm-bindgen/wasm-pack. This runs the code
transformation entirely in the browser, eliminating the server action
round-trip and serverless function cold starts on every keystroke.

- New packages/swc-playground-wasm Rust crate targeting wasm32-unknown-unknown
- Exposes transform() and transformAll() functions via wasm-bindgen
- Client loads WASM + JS glue from public/wasm/ as static assets
- Removed @swc/core and @workflow/swc-plugin server-side dependencies
- Removed serverExternalPackages/outputFileTracingIncludes Next.js config
- Added WASM loading indicator and error state in the UI
- Reduced transform debounce from 500ms to 300ms (no network latency)

* Add missing extends key to swc-playground-wasm turbo.json

* Fix build: ensure rustup is available for wasm32-unknown-unknown target

The Vercel build environment has a system Rust without rustup, so
the wasm32-unknown-unknown target can't be managed. Now the build
script checks for rustup specifically (not just cargo) and installs
it when missing, matching the pattern in swc-plugin-workflow/build.js.

* Add workflow type definitions to Monaco editor for intellisense

Auto-generate type declarations from built .d.ts files of workflow,
@workflow/core, @workflow/errors, @workflow/world, and @workflow/utils
packages. Register them with Monaco's TypeScript language service via
addExtraLib() so imports like 'workflow' resolve with full types,
eliminating red squiggles and enabling autocomplete/hover info.

* Fix Monaco type resolution: register root index.d.ts for each package

Monaco's NodeJs module resolution looks for index.d.ts at the package
root, not just in dist/. Register the main entry .d.ts content at both
paths (dist/ and root) so bare imports resolve with full type info.

Also remove the 2307 diagnostic suppression so non-existent imports
correctly show errors.

* Fix Monaco tooltip overflow by enabling fixedOverflowWidgets

* Fix hydration mismatch, Monaco type resolution paths, and WASM init warning

- Fix hydration mismatch: gate Reset button disabled state on isHydrated
  so server and client render consistently during hydration
- Fix Monaco types: use bare node_modules/ paths instead of file:///
  URIs for addExtraLib, which is what Monaco's NodeJs resolver expects
- Remove virtual package.json entries (Monaco doesn't use them)
- Fix wasm-bindgen init deprecation: pass object { module_or_path }
  instead of bare string argument

* Fix Monaco module resolution: set model URI to file:/// and match addExtraLib paths

Monaco's TypeScript NodeJs resolver needs the editor model and the
addExtraLib entries to share the same URI scheme. Set the input editor
model path to file:///src/input.tsx and register all type declarations
at file:///node_modules/... so resolution of bare imports like
'workflow' correctly finds the virtual node_modules.

Also register a root index.d.ts for packages like @workflow/world that
lack an explicit 'types' field in their package.json exports.

* Use declare module ambient declarations for reliable Monaco type resolution

Replace the virtual node_modules filesystem approach with declare module
ambient declarations. This is the standard approach used by TypeScript
Playground and StackBlitz — it works regardless of Monaco's internal URI
scheme and module resolution quirks.

The generation script now:
- Registers all .d.ts files at file:///node_modules/<pkg>/dist/... paths
- Generates a global ambient declarations file with declare module blocks
  that map bare import specifiers to their .d.ts entry points
- Includes @workflow/serde and workflow sub-exports (api, errors, observability)
- Supports configurable sub-export mappings per package

* Inline types into declare module blocks for full Monaco type support

Replace the export-from-file approach with fully inlined declare module
blocks. The script now reads each .d.ts entry point, recursively inlines
all relative imports, strips external import statements (resolved via
other declare module blocks), and produces a single ambient declarations
string.

This correctly handles:
- unique symbol exports (@workflow/serde)
- cross-package re-exports (workflow re-exporting from @workflow/core)
- JSDoc comments preserved for hover documentation
- Sub-path exports (workflow/api, workflow/errors, workflow/observability)
- Added @workflow/serde package

* Add workspace packages as dependencies so Turbo builds their types

The generate-monaco-types script reads .d.ts files from the built
dist/ directories of workflow, @workflow/core, @workflow/errors, etc.
On Vercel, Turbo only builds explicit dependencies — without these
workspace references, the packages were never built and the dist/
directories didn't exist, resulting in 0 type modules generated.

* Add @types/node declarations to Monaco editor

Register all @types/node .d.ts files via addExtraLib so Node.js
built-in modules (crypto, fs, path, etc.) are available in the
playground editor with full type information.

* Collect @types/node .d.ts files recursively to include subpath modules

The previous non-recursive scan missed subdirectory files like
fs/promises.d.ts, stream/web.d.ts, dns/promises.d.ts, etc., causing
'Cannot find module node:fs/promises' errors.
This commit is contained in:
Nathan Rajlich
2026-03-30 11:17:53 -07:00
committed by GitHub
parent a0a71957ef
commit 781d64b737
18 changed files with 894 additions and 120 deletions
Generated
+15
View File
@@ -1619,6 +1619,21 @@ dependencies = [
"syn",
]
[[package]]
name = "swc_playground_wasm"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"swc_common",
"swc_ecma_ast",
"swc_ecma_codegen",
"swc_ecma_parser",
"swc_ecma_visit",
"swc_workflow",
"wasm-bindgen",
]
[[package]]
name = "swc_plugin"
version = "1.0.1"
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace]
members = ["packages/swc-plugin-workflow"]
members = ["packages/swc-plugin-workflow", "packages/swc-playground-wasm"]
resolver = "2"
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "swc_playground_wasm"
version = "0.1.0"
authors = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
publish = false
rust-version = { workspace = true }
[lib]
crate-type = ["cdylib"]
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
swc_common = "18.0"
swc_ecma_ast = "19.0"
swc_ecma_codegen = "21.0"
swc_ecma_parser = { version = "28.0", default-features = false, features = ["typescript"] }
swc_ecma_visit = "19.0"
swc_workflow = { path = "../swc-plugin-workflow/transform" }
wasm-bindgen = "0.2"
+101
View File
@@ -0,0 +1,101 @@
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
function runCommand(command) {
try {
execSync(command, { stdio: 'inherit', shell: true });
} catch (error) {
console.error(`Command failed: ${command}: ${error}`);
process.exit(1);
}
}
function commandExists(command) {
try {
execSync(`${command} --version`, { stdio: 'ignore', shell: true });
return true;
} catch {
return false;
}
}
function ensureRustup() {
if (commandExists('rustup')) return;
// rustup is not available — the system may have a non-rustup Rust install
// (e.g. Vercel build environment). Install rustup so we can manage targets.
if (process.env.CI) {
console.log('Installing Rust via rustup...');
if (process.platform === 'win32') {
runCommand(
'powershell -Command "iwr https://win.rustup.rs -OutFile rustup-init.exe; .\\rustup-init.exe -y --profile minimal; del rustup-init.exe"'
);
} else {
runCommand(
'curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal'
);
}
// Add rustup's cargo to PATH so it takes precedence over any system Rust
const cargoPath = `${process.env.HOME}/.cargo/bin`;
process.env.PATH = `${cargoPath}:${process.env.PATH}`;
console.log('Rust installed and PATH updated');
} else {
console.error('Rust is required but not installed.');
console.error(
'Please visit https://rustup.rs and follow the installation instructions.'
);
console.error(
'After installing, run "rustup target add wasm32-unknown-unknown"'
);
process.exit(1);
}
}
console.log('Building swc-playground-wasm...');
ensureRustup();
// Check if wasm32-unknown-unknown target exists and install if needed
console.log('Checking wasm32-unknown-unknown target...');
try {
const installedTargets = execSync('rustup target list --installed', {
stdio: 'pipe',
shell: true,
}).toString();
if (!installedTargets.includes('wasm32-unknown-unknown')) {
console.log('wasm32-unknown-unknown target not found, installing...');
runCommand('rustup target add wasm32-unknown-unknown');
} else {
console.log('wasm32-unknown-unknown target already installed');
}
} catch (error) {
console.error(
'Failed to check/install wasm32-unknown-unknown target:',
error.message
);
process.exit(1);
}
// Check if wasm-pack is installed
if (!commandExists('wasm-pack')) {
console.log('Installing wasm-pack...');
runCommand('cargo install wasm-pack');
}
// Build with wasm-pack targeting web (browser ESM)
console.log('Running wasm-pack build...');
const pkgDir = fileURLToPath(new URL('.', import.meta.url));
const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url));
runCommand(`wasm-pack build --target web --out-dir pkg --release ${pkgDir}`, {
cwd: workspaceRoot,
});
// Verify output exists
const wasmFile = new URL('pkg/swc_playground_wasm_bg.wasm', import.meta.url);
if (!existsSync(wasmFile)) {
console.error('Build failed: WASM file not found in pkg/');
process.exit(1);
}
console.log('Build complete!');
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@workflow/swc-playground-wasm",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "node build.js",
"clean": "rm -rf pkg"
},
"files": [
"pkg"
]
}
+209
View File
@@ -0,0 +1,209 @@
use serde::{Deserialize, Serialize};
use swc_common::{
errors::{DiagnosticBuilder, Handler, HANDLER},
sync::Lrc,
FileName, SourceMap, GLOBALS,
};
use swc_ecma_ast::EsVersion;
use swc_ecma_codegen::{text_writer::JsWriter, Emitter};
use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax, TsSyntax};
use swc_ecma_visit::VisitMutWith;
use swc_workflow::{StepTransform, TransformMode};
use wasm_bindgen::prelude::*;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TransformConfig {
mode: TransformMode,
#[serde(default)]
module_specifier: Option<String>,
#[serde(default = "default_filename")]
filename: String,
}
fn default_filename() -> String {
"input.ts".to_string()
}
#[derive(Serialize, Clone)]
struct TransformOutput {
code: String,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Serialize)]
struct BatchOutput {
workflow: TransformOutput,
step: TransformOutput,
client: TransformOutput,
}
/// Custom emitter that silently consumes diagnostics.
///
/// The SWC transform emits errors via the HANDLER thread-local.
/// This emitter prevents the handler from panicking when diagnostics
/// are emitted — the transform still produces useful output even
/// when there are diagnostic warnings/errors.
#[derive(Default)]
struct SilentEmitter;
impl swc_common::errors::Emitter for SilentEmitter {
fn emit(&mut self, _db: &mut DiagnosticBuilder<'_>) {
// Silently consume diagnostics.
}
}
fn transform_single(source: &str, config: &TransformConfig) -> TransformOutput {
let cm: Lrc<SourceMap> = Lrc::new(SourceMap::default());
let fm = cm.new_source_file(
Lrc::new(FileName::Custom(config.filename.clone())),
source.to_string(),
);
let handler = Handler::with_emitter(true, false, Box::new(SilentEmitter));
GLOBALS.set(&swc_common::Globals::new(), || {
HANDLER.set(&handler, || {
let lexer = Lexer::new(
Syntax::Typescript(TsSyntax {
tsx: true,
..Default::default()
}),
EsVersion::Es2022,
StringInput::from(&*fm),
None,
);
let mut parser = Parser::new_from(lexer);
let mut program = match parser.parse_program() {
Ok(p) => p,
Err(e) => {
return TransformOutput {
code: String::new(),
error: Some(format!("Parse error: {}", e.kind().msg())),
};
}
};
// Check for additional parse errors emitted via diagnostics
for e in parser.take_errors() {
return TransformOutput {
code: String::new(),
error: Some(format!("Parse error: {}", e.kind().msg())),
};
}
let mut visitor = StepTransform::new(
config.mode.clone(),
config.filename.clone(),
config.module_specifier.clone(),
);
program.visit_mut_with(&mut visitor);
let mut buf = vec![];
{
let writer = JsWriter::new(cm.clone(), "\n", &mut buf, None);
let mut emitter = Emitter {
cfg: swc_ecma_codegen::Config::default()
.with_target(EsVersion::Es2022)
.with_minify(false),
cm: cm.clone(),
comments: None,
wr: writer,
};
if let Err(e) = emitter.emit_program(&program) {
return TransformOutput {
code: String::new(),
error: Some(format!("Codegen error: {}", e)),
};
}
}
let code = String::from_utf8(buf).unwrap_or_default();
TransformOutput { code, error: None }
})
})
}
/// Transform source code using the workflow SWC plugin.
///
/// `config_json` should be a JSON string like:
/// `{"mode": "workflow", "moduleSpecifier": "my-package@1.0.0", "filename": "input.ts"}`
///
/// Returns a JSON string with `{"code": "...", "error": "..."}`.
#[wasm_bindgen]
pub fn transform(source: &str, config_json: &str) -> String {
let config: TransformConfig = match serde_json::from_str(config_json) {
Ok(c) => c,
Err(e) => {
let output = TransformOutput {
code: String::new(),
error: Some(format!("Invalid config: {}", e)),
};
return serde_json::to_string(&output).unwrap();
}
};
let output = transform_single(source, &config);
serde_json::to_string(&output).unwrap()
}
/// Transform source code in all three modes at once (workflow, step, client).
///
/// `config_json` should be a JSON string like:
/// `{"moduleSpecifier": "my-package@1.0.0", "filename": "input.ts"}`
///
/// Returns a JSON string with `{"workflow": {...}, "step": {...}, "client": {...}}`.
#[wasm_bindgen(js_name = "transformAll")]
pub fn transform_all(source: &str, config_json: &str) -> String {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct BatchConfig {
#[serde(default)]
module_specifier: Option<String>,
#[serde(default = "default_filename")]
filename: String,
}
let batch_config: BatchConfig = match serde_json::from_str(config_json) {
Ok(c) => c,
Err(e) => {
let error_output = TransformOutput {
code: String::new(),
error: Some(format!("Invalid config: {}", e)),
};
let output = BatchOutput {
workflow: error_output.clone(),
step: error_output.clone(),
client: error_output,
};
return serde_json::to_string(&output).unwrap();
}
};
let modes = [
TransformMode::Workflow,
TransformMode::Step,
TransformMode::Client,
];
let mut results = Vec::with_capacity(3);
for mode in &modes {
let config = TransformConfig {
mode: mode.clone(),
module_specifier: batch_config.module_specifier.clone(),
filename: batch_config.filename.clone(),
};
results.push(transform_single(source, &config));
}
let output = BatchOutput {
workflow: results.remove(0),
step: results.remove(0),
client: results.remove(0),
};
serde_json::to_string(&output).unwrap()
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"outputs": ["pkg/**"],
"env": ["RUSTUP_HOME", "CARGO_HOME"]
}
}
}
+28 -7
View File
@@ -898,6 +898,8 @@ importers:
specifier: 7.1.12
version: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)
packages/swc-playground-wasm: {}
packages/swc-plugin-workflow:
dependencies:
'@swc/core':
@@ -2149,15 +2151,27 @@ importers:
'@monaco-editor/react':
specifier: latest
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@swc/core':
specifier: 1.15.3
version: 1.15.3
'@vercel/analytics':
specifier: latest
version: 2.0.1(baf0aac73cdb99e7b4aee2bd440a0745)
'@workflow/swc-plugin':
'@workflow/core':
specifier: workspace:*
version: link:../../packages/swc-plugin-workflow
version: link:../../packages/core
'@workflow/errors':
specifier: workspace:*
version: link:../../packages/errors
'@workflow/serde':
specifier: workspace:*
version: link:../../packages/serde
'@workflow/swc-playground-wasm':
specifier: workspace:*
version: link:../../packages/swc-playground-wasm
'@workflow/utils':
specifier: workspace:*
version: link:../../packages/utils
'@workflow/world':
specifier: workspace:*
version: link:../../packages/world
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -2194,6 +2208,9 @@ importers:
tailwind-merge:
specifier: ^2.5.5
version: 2.5.5
workflow:
specifier: workspace:*
version: link:../../packages/workflow
devDependencies:
'@tailwindcss/postcss':
specifier: ^4.1.9
@@ -23148,6 +23165,10 @@ snapshots:
dependencies:
acorn: 8.15.0
acorn-import-attributes@1.9.5(acorn@8.16.0):
dependencies:
acorn: 8.16.0
acorn-import-phases@1.0.4(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@@ -26141,8 +26162,8 @@ snapshots:
import-in-the-middle@1.15.0:
dependencies:
acorn: 8.15.0
acorn-import-attributes: 1.9.5(acorn@8.15.0)
acorn: 8.16.0
acorn-import-attributes: 1.9.5(acorn@8.16.0)
cjs-module-lexer: 1.4.3
module-details-from-path: 1.0.4
+6
View File
@@ -4,3 +4,9 @@
next-env.d.ts
node_modules
tsconfig.tsbuildinfo
# WASM build artifacts (copied from packages/swc-playground-wasm/pkg)
public/wasm/
# Auto-generated Monaco type definitions
lib/generated-types.ts
+5 -22
View File
@@ -1,29 +1,12 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { SwcPlayground } from '@/components/swc-playground';
function getPluginVersion(): string {
try {
// Read directly from node_modules
const pkgJsonPath = join(
process.cwd(),
'node_modules/@workflow/swc-plugin/package.json'
);
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
return pkgJson.version;
} catch (error) {
console.error(
'Failed to read @workflow/swc-plugin version from package.json:',
error
);
return 'unknown';
}
}
import pluginPkg from '../../../packages/swc-plugin-workflow/package.json';
export default function Page() {
const pluginVersion = getPluginVersion();
const gitCommitSha = process.env.VERCEL_GIT_COMMIT_SHA;
return (
<SwcPlayground pluginVersion={pluginVersion} gitCommitSha={gitCommitSha} />
<SwcPlayground
pluginVersion={pluginPkg.version}
gitCommitSha={gitCommitSha}
/>
);
}
@@ -85,6 +85,7 @@ export function CodeEditor({
fontSize: 14,
scrollBeyondLastLine: false,
automaticLayout: true,
fixedOverflowWidgets: true,
padding: { top: 16, bottom: 16 },
}}
onMount={handleMount}
@@ -1,14 +1,16 @@
'use client';
import type { Monaco } from '@monaco-editor/react';
import { AlertCircle, ChevronDownIcon, Loader2, RotateCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import { Switch } from '@/components/ui/switch';
import { transformCode } from '@/lib/transform-action';
import { nodeTypeDeclarations, typeDeclarations } from '@/lib/generated-types';
import { initWasm, transformCode } from '@/lib/transform';
import { CodeEditor } from './editor';
const STORAGE_KEY = 'swc-playground-code';
@@ -66,6 +68,8 @@ export function SwcPlayground({
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: '' },
@@ -75,6 +79,49 @@ export function SwcPlayground({
const [expandedPanels, setExpandedPanels] = useState<Set<ViewMode>>(
new Set(['workflow', 'step', 'client'])
);
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(() => {
@@ -122,6 +169,7 @@ export function SwcPlayground({
const compile = useCallback(
async (sourceCode: string) => {
if (!wasmReady) return;
setIsCompiling(true);
try {
@@ -132,7 +180,7 @@ export function SwcPlayground({
setResults(transformResults);
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Server error';
err instanceof Error ? err.message : 'Transform error';
setResults({
workflow: { code: '', error: errorMessage },
step: { code: '', error: errorMessage },
@@ -142,15 +190,16 @@ export function SwcPlayground({
setIsCompiling(false);
}
},
[moduleSpecifier]
[moduleSpecifier, wasmReady]
);
useEffect(() => {
if (!wasmReady) return;
const timer = setTimeout(() => {
compile(code);
}, 500);
}, 300);
return () => clearTimeout(timer);
}, [code, compile]);
}, [code, compile, wasmReady]);
const togglePanel = (mode: ViewMode) => {
setExpandedPanels((prev) => {
@@ -174,6 +223,18 @@ export function SwcPlayground({
<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}`}
@@ -226,7 +287,7 @@ export function SwcPlayground({
<button
type="button"
onClick={() => setCode(DEFAULT_CODE)}
disabled={code === 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"
>
@@ -240,6 +301,7 @@ export function SwcPlayground({
value={code}
onChange={(val) => setCode(val || '')}
vimMode={vimMode}
onMount={(_editor, monaco) => configureMonaco(monaco)}
/>
</div>
</div>
@@ -1,76 +0,0 @@
'use server';
import fs from 'node:fs';
import path from 'node:path';
import swc from '@swc/core';
// Construct the WASM path directly to avoid Turbopack trying to bundle it
// Use process.cwd() to get project root
const wasmPath = path.join(
process.cwd(),
'node_modules/@workflow/swc-plugin/swc_plugin_workflow.wasm'
);
try {
fs.statSync(wasmPath);
} catch (err) {
const originalMessage =
err instanceof Error ? ` Original error: ${err.message}` : '';
throw new Error(
`SWC plugin WASM file not found or not accessible at path: ${wasmPath}.${originalMessage}`
);
}
export interface TransformResult {
workflow: { code: string; error?: string };
step: { code: string; error?: string };
client: { code: string; error?: string };
}
export async function transformCode(
sourceCode: string,
moduleSpecifier?: string
): Promise<TransformResult> {
const modes = ['workflow', 'step', 'client'] as const;
const results: TransformResult = {
workflow: { code: '' },
step: { code: '' },
client: { code: '' },
};
await Promise.all(
modes.map(async (mode) => {
try {
const output = await swc.transform(sourceCode, {
filename: 'input.ts',
swcrc: false,
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
},
target: 'es2022',
experimental: {
plugins: [[wasmPath, { mode, moduleSpecifier }]],
},
},
module: {
type: 'es6',
},
});
results[mode] = { code: output.code };
} catch (err) {
const errorMessage =
err instanceof Error
? err.message
: 'Compilation failed. Check server logs for details.';
results[mode] = {
code: '',
error: errorMessage,
};
}
})
);
return results;
}
+61
View File
@@ -0,0 +1,61 @@
export interface TransformResult {
workflow: { code: string; error?: string };
step: { code: string; error?: string };
client: { code: string; error?: string };
}
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;
}
-4
View File
@@ -1,9 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ['@swc/core', '@workflow/swc-plugin'],
outputFileTracingIncludes: {
'/*': ['node_modules/@workflow/swc-plugin/swc_plugin_workflow.wasm'],
},
typescript: {
ignoreBuildErrors: true,
},
+12 -3
View File
@@ -2,17 +2,26 @@
"name": "workflow-devkit-compiler-playground",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"copy-wasm": "node scripts/copy-wasm.js",
"generate-types": "node scripts/generate-monaco-types.js",
"prebuild": "node scripts/copy-wasm.js && node scripts/generate-monaco-types.js",
"build": "next build",
"dev": "next dev",
"dev": "node scripts/copy-wasm.js && node scripts/generate-monaco-types.js && next dev",
"lint": "eslint .",
"start": "next start"
},
"dependencies": {
"@monaco-editor/react": "latest",
"@swc/core": "1.15.3",
"@vercel/analytics": "latest",
"@workflow/swc-plugin": "workspace:*",
"@workflow/core": "workspace:*",
"@workflow/errors": "workspace:*",
"@workflow/serde": "workspace:*",
"@workflow/swc-playground-wasm": "workspace:*",
"@workflow/utils": "workspace:*",
"@workflow/world": "workspace:*",
"workflow": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.454.0",
@@ -0,0 +1,30 @@
/**
* Copies the WASM build artifacts from packages/swc-playground-wasm/pkg/
* into public/wasm/ so they can be served as static assets.
*/
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
const pkgDir = new URL(
'../../../packages/swc-playground-wasm/pkg/',
import.meta.url
);
if (!existsSync(pkgDir)) {
console.error(
`WASM package not found at ${pkgDir}.\n` +
'Run "pnpm build" in packages/swc-playground-wasm first.'
);
process.exit(1);
}
const publicWasmDir = new URL('../public/wasm/', import.meta.url);
mkdirSync(publicWasmDir, { recursive: true });
// Copy the .wasm binary and JS glue to public/ (served as static assets via CDN)
const files = ['swc_playground_wasm_bg.wasm', 'swc_playground_wasm.js'];
for (const file of files) {
copyFileSync(new URL(file, pkgDir), new URL(file, publicWasmDir));
}
console.log('WASM artifacts copied successfully.');
@@ -0,0 +1,308 @@
/**
* Generates a TypeScript file containing ambient module declarations from
* workspace packages, for use with Monaco editor's `addExtraLib()` API.
*
* Reads the built `.d.ts` files from workspace packages and generates
* `declare module` blocks with inlined type content. This is the most
* reliable approach for Monaco as it works regardless of module resolution
* configuration.
*/
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from 'node:fs';
const packagesDir = new URL('../../../packages/', import.meta.url);
// Each entry defines a module name and the .d.ts file that provides its types.
// `dir` is the directory name under packages/.
// `modules` maps module specifiers to .d.ts paths relative to the package root.
const PACKAGES = [
{
dir: 'world',
modules: { '@workflow/world': './dist/index.d.ts' },
},
{
dir: 'utils',
modules: { '@workflow/utils': './dist/index.d.ts' },
},
{
dir: 'serde',
modules: { '@workflow/serde': './dist/index.d.ts' },
},
{
dir: 'errors',
modules: { '@workflow/errors': './dist/index.d.ts' },
},
{
dir: 'core',
modules: { '@workflow/core': './dist/index.d.ts' },
},
{
dir: 'workflow',
modules: {
workflow: './dist/index.d.ts',
'workflow/api': './dist/api.d.ts',
'workflow/errors': './dist/internal/errors.d.ts',
'workflow/observability': './dist/observability.d.ts',
},
},
];
/**
* Read a .d.ts file and transform it for use inside a `declare module` block.
*
* - Strips `export {};` lines
* - Strips `//# sourceMappingURL=` lines
* - Converts `export { X, Y } from './foo.js'` re-exports by reading
* the referenced file and inlining the exported declarations
* - Converts `export * from '@workflow/core'` into `export * from "@workflow/core"`
* (these work because the referenced module also has a declare module block)
* - Converts `import type { X } from './foo.js'` to inline the referenced types
* - Strips `import type` statements for external packages (they'll resolve
* via other declare module blocks)
*/
function readDtsForModule(pkgDir, dtsRelPath, visited = new Set()) {
const dtsUrl = new URL(dtsRelPath.replace(/^\.\//, ''), pkgDir);
const key = dtsUrl.href;
if (visited.has(key)) return ''; // prevent cycles
visited.add(key);
if (!existsSync(dtsUrl)) {
console.warn(` Warning: ${dtsUrl} not found`);
return '';
}
let content = readFileSync(dtsUrl, 'utf-8');
// Strip source map references
content = content.replace(/\/\/# sourceMappingURL=.*$/gm, '');
// Strip bare `export {};`
content = content.replace(/^export \{\s*\};\s*$/gm, '');
// Process `export * from './relative.js'` — inline from local files
content = content.replace(
/^export \* from ['"](\.[^'"]+)['"]\s*;?\s*$/gm,
(_match, relPath) => {
const resolvedPath = resolveRelativeDts(dtsRelPath, relPath);
return readDtsForModule(pkgDir, resolvedPath, visited);
}
);
// Process `export { X, Y, ... } from './relative.js'` — inline from local files
content = content.replace(
/^export \{([^}]+)\} from ['"](\.[^'"]+)['"]\s*;?\s*$/gm,
(_match, exports, relPath) => {
const resolvedPath = resolveRelativeDts(dtsRelPath, relPath);
const sourceContent = readDtsForModule(pkgDir, resolvedPath, visited);
// Return the full source — TypeScript will use what it needs.
// This is simpler than trying to cherry-pick individual declarations.
return sourceContent;
}
);
// Convert `import type { X } from './relative.js'` to inline
// We need these types available, so read and inline the source
content = content.replace(
/^import type \{([^}]+)\} from ['"](\.[^'"]+)['"]\s*;?\s*$/gm,
(_match, _imports, relPath) => {
const resolvedPath = resolveRelativeDts(dtsRelPath, relPath);
return readDtsForModule(pkgDir, resolvedPath, visited);
}
);
// Convert `import { type X } from './relative.js'` (mixed imports)
content = content.replace(
/^import \{([^}]+)\} from ['"](\.[^'"]+)['"]\s*;?\s*$/gm,
(_match, _imports, relPath) => {
const resolvedPath = resolveRelativeDts(dtsRelPath, relPath);
return readDtsForModule(pkgDir, resolvedPath, visited);
}
);
// Strip remaining import statements for external packages
// (they resolve via other declare module blocks)
content = content.replace(
/^import\s+(?:type\s+)?\{[^}]*\}\s+from\s+['"][^.][^'"]*['"]\s*;?\s*$/gm,
''
);
// Strip `export type { X } from '...'` for external packages
// (the types are available from the other declare module blocks)
// But keep `export * from '@...'` as those re-export from declared modules
// Remove `declare` keyword — it's redundant inside `declare module`
content = content.replace(/^export declare /gm, 'export ');
// Clean up multiple blank lines
content = content.replace(/\n{3,}/g, '\n\n');
return content.trim();
}
/**
* Resolve a relative .d.ts import path against the importing file's path.
* Handles .js -> .d.ts extension mapping.
*/
function resolveRelativeDts(fromPath, relativePath) {
// Convert .js extension to .d.ts
let resolved = relativePath.replace(/\.js$/, '.d.ts');
// Resolve relative to the importing file's directory
const fromDir = fromPath.replace(/\/[^/]+$/, '/');
if (resolved.startsWith('./')) {
resolved = fromDir + resolved.slice(2);
} else if (resolved.startsWith('../')) {
// Handle ../ by going up from fromDir
const parts = fromDir.split('/').filter(Boolean);
const relParts = resolved.split('/');
for (const part of relParts) {
if (part === '..') {
parts.pop();
} else if (part !== '.') {
parts.push(part);
}
}
resolved = './' + parts.join('/');
}
return resolved;
}
// Build the output
const declareModules = [];
let totalFiles = 0;
for (const pkgConfig of PACKAGES) {
const { dir, modules } = pkgConfig;
const pkgDir = new URL(`${dir}/`, packagesDir);
if (!existsSync(pkgDir)) {
console.warn(`Skipping ${dir}: directory not found`);
continue;
}
console.log(`Processing ${dir}...`);
for (const [moduleName, dtsPath] of Object.entries(modules)) {
const content = readDtsForModule(pkgDir, dtsPath);
if (content) {
declareModules.push({ moduleName, content });
totalFiles++;
console.log(` "${moduleName}" -> ${dtsPath}`);
}
}
}
// Build the final declarations string
let declarations = '// Auto-generated by scripts/generate-monaco-types.js\n\n';
// Add third-party type stubs
declarations += `
declare module "ms" {
export type StringValue =
| \`\${number}ms\`
| \`\${number}s\`
| \`\${number}m\`
| \`\${number}h\`
| \`\${number}d\`
| \`\${number}w\`
| \`\${number}y\`
| (string & {});
}
declare module "@standard-schema/spec" {
export interface StandardSchemaV1<Input = unknown, Output = Input> {
readonly "~standard": StandardSchemaV1.Props<Input, Output>;
}
export namespace StandardSchemaV1 {
interface Props<Input = unknown, Output = Input> {
readonly version: 1;
readonly vendor: string;
readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
readonly types?: Types<Input, Output>;
}
interface Types<Input = unknown, Output = Input> {
readonly input: Input;
readonly output: Output;
}
type Result<Output> = SuccessResult<Output> | FailureResult;
interface SuccessResult<Output> { readonly value: Output; readonly issues?: undefined; }
interface FailureResult { readonly issues: readonly Issue[]; }
interface Issue { readonly message: string; readonly path?: readonly (string | number | symbol)[]; }
}
}
`;
// Add workspace package declarations
for (const { moduleName, content } of declareModules) {
declarations += `declare module "${moduleName}" {\n`;
// Indent the content
const indented = content
.split('\n')
.map((line) => (line.trim() ? ` ${line}` : ''))
.join('\n');
declarations += indented;
declarations += `\n}\n\n`;
}
// Collect @types/node declarations (recursively, including subdirectories
// like fs/promises.d.ts, stream/web.d.ts, etc.)
const nodeTypesDir = new URL('../node_modules/@types/node/', import.meta.url);
const nodeTypesFiles = [];
function collectNodeTypes(dirUrl, relativeTo) {
const entries = readdirSync(dirUrl, { withFileTypes: true });
for (const entry of entries) {
const entryUrl = new URL(
`${entry.name}${entry.isDirectory() ? '/' : ''}`,
dirUrl
);
if (entry.isDirectory() && entry.name !== 'node_modules') {
collectNodeTypes(entryUrl, relativeTo);
} else if (entry.name.endsWith('.d.ts')) {
const content = readFileSync(entryUrl, 'utf-8');
// Compute path relative to @types/node/
const relPath = entryUrl.pathname.slice(relativeTo.pathname.length);
nodeTypesFiles.push({ name: relPath, content });
}
}
}
if (existsSync(nodeTypesDir)) {
console.log('Processing @types/node...');
collectNodeTypes(nodeTypesDir, nodeTypesDir);
console.log(` Collected ${nodeTypesFiles.length} .d.ts files`);
} else {
console.warn(' @types/node not found, skipping');
}
// Write output
const outputUrl = new URL('../lib/generated-types.ts', import.meta.url);
mkdirSync(new URL('.', outputUrl), { recursive: true });
const nodeTypes = nodeTypesFiles.map((f) => f.content).join('\n');
const output = `// Auto-generated by scripts/generate-monaco-types.js — DO NOT EDIT
export const typeDeclarations: string = ${JSON.stringify(declarations)};
export const nodeTypeDeclarations: Record<string, string> = ${JSON.stringify(
Object.fromEntries(
nodeTypesFiles.map((f) => [
`file:///node_modules/@types/node/${f.name}`,
f.content,
])
)
)};
`;
writeFileSync(outputUrl, output);
const totalSize = declarations.length + nodeTypes.length;
console.log(
`\nGenerated lib/generated-types.ts (${totalFiles} modules + @types/node, ${(totalSize / 1024).toFixed(1)}KB)`
);