From 781d64b7373b10ebdac1921285aff87491e44061 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Mon, 30 Mar 2026 11:17:53 -0700 Subject: [PATCH] Move SWC playground to client-side WASM and add Monaco type intellisense (#1553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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//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. --- Cargo.lock | 15 + Cargo.toml | 2 +- packages/swc-playground-wasm/Cargo.toml | 25 ++ packages/swc-playground-wasm/build.js | 101 ++++++ packages/swc-playground-wasm/package.json | 13 + packages/swc-playground-wasm/src/lib.rs | 209 ++++++++++++ packages/swc-playground-wasm/turbo.json | 10 + pnpm-lock.yaml | 35 +- workbench/swc-playground/.gitignore | 6 + workbench/swc-playground/app/page.tsx | 27 +- .../swc-playground/components/editor.tsx | 1 + .../components/swc-playground.tsx | 76 ++++- .../swc-playground/lib/transform-action.ts | 76 ----- workbench/swc-playground/lib/transform.ts | 61 ++++ workbench/swc-playground/next.config.mjs | 4 - workbench/swc-playground/package.json | 15 +- workbench/swc-playground/scripts/copy-wasm.js | 30 ++ .../scripts/generate-monaco-types.js | 308 ++++++++++++++++++ 18 files changed, 894 insertions(+), 120 deletions(-) create mode 100644 packages/swc-playground-wasm/Cargo.toml create mode 100644 packages/swc-playground-wasm/build.js create mode 100644 packages/swc-playground-wasm/package.json create mode 100644 packages/swc-playground-wasm/src/lib.rs create mode 100644 packages/swc-playground-wasm/turbo.json delete mode 100644 workbench/swc-playground/lib/transform-action.ts create mode 100644 workbench/swc-playground/lib/transform.ts create mode 100644 workbench/swc-playground/scripts/copy-wasm.js create mode 100644 workbench/swc-playground/scripts/generate-monaco-types.js diff --git a/Cargo.lock b/Cargo.lock index 867c8007c..9c74a8092 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 3dc6c3c68..c427bd578 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["packages/swc-plugin-workflow"] +members = ["packages/swc-plugin-workflow", "packages/swc-playground-wasm"] resolver = "2" diff --git a/packages/swc-playground-wasm/Cargo.toml b/packages/swc-playground-wasm/Cargo.toml new file mode 100644 index 000000000..d1b5a3928 --- /dev/null +++ b/packages/swc-playground-wasm/Cargo.toml @@ -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" diff --git a/packages/swc-playground-wasm/build.js b/packages/swc-playground-wasm/build.js new file mode 100644 index 000000000..2ac8b99a2 --- /dev/null +++ b/packages/swc-playground-wasm/build.js @@ -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!'); diff --git a/packages/swc-playground-wasm/package.json b/packages/swc-playground-wasm/package.json new file mode 100644 index 000000000..736d70dfb --- /dev/null +++ b/packages/swc-playground-wasm/package.json @@ -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" + ] +} diff --git a/packages/swc-playground-wasm/src/lib.rs b/packages/swc-playground-wasm/src/lib.rs new file mode 100644 index 000000000..ea2922407 --- /dev/null +++ b/packages/swc-playground-wasm/src/lib.rs @@ -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, + #[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, +} + +#[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 = 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, + #[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() +} diff --git a/packages/swc-playground-wasm/turbo.json b/packages/swc-playground-wasm/turbo.json new file mode 100644 index 000000000..168a9597d --- /dev/null +++ b/packages/swc-playground-wasm/turbo.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["pkg/**"], + "env": ["RUSTUP_HOME", "CARGO_HOME"] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5aa47b0b6..70311f2e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/workbench/swc-playground/.gitignore b/workbench/swc-playground/.gitignore index 336035073..63126854f 100644 --- a/workbench/swc-playground/.gitignore +++ b/workbench/swc-playground/.gitignore @@ -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 diff --git a/workbench/swc-playground/app/page.tsx b/workbench/swc-playground/app/page.tsx index 36bb7f8c3..13f77e20d 100644 --- a/workbench/swc-playground/app/page.tsx +++ b/workbench/swc-playground/app/page.tsx @@ -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 ( - + ); } diff --git a/workbench/swc-playground/components/editor.tsx b/workbench/swc-playground/components/editor.tsx index f19d011bf..22a77cfef 100644 --- a/workbench/swc-playground/components/editor.tsx +++ b/workbench/swc-playground/components/editor.tsx @@ -85,6 +85,7 @@ export function CodeEditor({ fontSize: 14, scrollBeyondLastLine: false, automaticLayout: true, + fixedOverflowWidgets: true, padding: { top: 16, bottom: 16 }, }} onMount={handleMount} diff --git a/workbench/swc-playground/components/swc-playground.tsx b/workbench/swc-playground/components/swc-playground.tsx index 2bfbf110b..9f188b910 100644 --- a/workbench/swc-playground/components/swc-playground.tsx +++ b/workbench/swc-playground/components/swc-playground.tsx @@ -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(null); const [results, setResults] = useState>({ workflow: { code: '' }, step: { code: '' }, @@ -75,6 +79,49 @@ export function SwcPlayground({ const [expandedPanels, setExpandedPanels] = useState>( 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({ @workflow/swc-plugin{pluginVersion ? `@${pluginVersion}` : ''} + {!wasmReady && !wasmError && ( + + + Loading WASM... + + )} + {wasmError && ( + + + WASM failed to load + + )} {gitCommitSha && ( 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)} /> diff --git a/workbench/swc-playground/lib/transform-action.ts b/workbench/swc-playground/lib/transform-action.ts deleted file mode 100644 index f3167b375..000000000 --- a/workbench/swc-playground/lib/transform-action.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/workbench/swc-playground/lib/transform.ts b/workbench/swc-playground/lib/transform.ts new file mode 100644 index 000000000..4bc8d4a4b --- /dev/null +++ b/workbench/swc-playground/lib/transform.ts @@ -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 | null = null; + +/** + * Initialize the WASM module. Safe to call multiple times — + * subsequent calls are no-ops. + */ +export async function initWasm(): Promise { + 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 { + await initWasm(); + + const config = JSON.stringify({ + moduleSpecifier, + filename: 'input.ts', + }); + + const resultJson = wasmExports!.transformAll(sourceCode, config); + return JSON.parse(resultJson) as TransformResult; +} diff --git a/workbench/swc-playground/next.config.mjs b/workbench/swc-playground/next.config.mjs index 0b655ed82..bd3419136 100644 --- a/workbench/swc-playground/next.config.mjs +++ b/workbench/swc-playground/next.config.mjs @@ -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, }, diff --git a/workbench/swc-playground/package.json b/workbench/swc-playground/package.json index f929f07fc..5b71e452d 100644 --- a/workbench/swc-playground/package.json +++ b/workbench/swc-playground/package.json @@ -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", diff --git a/workbench/swc-playground/scripts/copy-wasm.js b/workbench/swc-playground/scripts/copy-wasm.js new file mode 100644 index 000000000..910040236 --- /dev/null +++ b/workbench/swc-playground/scripts/copy-wasm.js @@ -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.'); diff --git a/workbench/swc-playground/scripts/generate-monaco-types.js b/workbench/swc-playground/scripts/generate-monaco-types.js new file mode 100644 index 000000000..837e568a2 --- /dev/null +++ b/workbench/swc-playground/scripts/generate-monaco-types.js @@ -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 { + readonly "~standard": StandardSchemaV1.Props; + } + export namespace StandardSchemaV1 { + interface Props { + readonly version: 1; + readonly vendor: string; + readonly validate: (value: unknown) => Result | Promise>; + readonly types?: Types; + } + interface Types { + readonly input: Input; + readonly output: Output; + } + type Result = SuccessResult | FailureResult; + interface SuccessResult { 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 = ${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)` +);