Skip to Content

@ttsc/wasm

@ttsc/wasm is the browser runtime layer for people building a ttsc-powered playground, tutorial, or plugin demo. It packages the TypeScript-Go compiler as WebAssembly, adds a Worker-friendly JavaScript boot helper, and gives the Go runtime an in-memory filesystem so the compiler can read a virtual project without calling a server.

This guide is for runtime builders and plugin authors. If you only want to compile a local project, use ttsc instead. If you want to write the plugin first, start with Plugin Development and come back here after the native sidecar works.

API stability: experimental until v1.0. Public signatures may change between minor releases. Pin exact @ttsc/wasm versions in production playgrounds, especially when you ship a custom wasm binary.

When to reach for it

  • A docs-site playground where users edit TypeScript and see real ttsc diagnostics or emitted JavaScript without installing anything.
  • A plugin demo where your transform runs in the browser beside the same native CLI implementation.
  • A workshop or tutorial surface that needs real type-checking but cannot depend on a backend compiler service.

Do not use it as the normal production build path. The CLI is smaller, simpler, and easier to cache in CI.

What ships

ArtifactWhat it is
dist/ttsc.wasmThe default binary. It exposes vanilla build, check, and transform; no plugins are linked in.
dist/wasm_exec.jsThe Go JavaScript runtime loader used by the default binary. Serve it next to ttsc.wasm, or pass wasmExecUrl.
lib/, src/The JavaScript API: bootTtsc, createMemFS, parseResult, and the typed ITtscApi surface.
host/The Go helper package plugin authors import from their own main_wasm.go. It binds globalThis[apiName].
cmd/ttsc-wasmThe minimal base wasm entrypoint and native sanity CLI used to build dist/ttsc.wasm.
shim-vendor/Vendored TypeScript-Go shim modules used by the published Go module.

The package is intentionally plugin-agnostic. The ttsc.dev playground, typia, and any third-party plugin playground build their own wasm binary against the same host/ package.

Runtime model

The browser flow is:

  1. Your page starts a Web Worker.
  2. The Worker calls bootTtsc({ wasmUrl, wasmExecUrl, apiName }).
  3. bootTtsc installs the in-memory filesystem, imports wasm_exec.js, fetches the wasm, runs Go, and waits for globalThis[apiName + "Ready"].
  4. Your app writes /work/tsconfig.json and source files into the MemFS.
  5. Your app calls api.check, api.build, api.transform, or api.plugin.
  6. The Worker posts diagnostics, output, or plugin stdout/stderr back to the UI.

Run this in a Worker. Goโ€™s wasm runtime stays alive for the lifetime of the binary; booting it on the main thread can freeze the page. Use a classic Worker or a bundler target that still exposes importScripts, because bootTtsc loads wasm_exec.js that way before it starts Go.

Use the base binary

The base dist/ttsc.wasm is useful when you need TypeScript-Go in the browser without plugin behavior.

npm install -D @ttsc/wasm mkdir -p public/compiler cp node_modules/@ttsc/wasm/dist/ttsc.wasm public/compiler/ttsc.wasm cp node_modules/@ttsc/wasm/dist/wasm_exec.js public/compiler/wasm_exec.js

Then boot it from a Worker:

import { type ITtscCompileResult, bootTtsc, parseResult } from "@ttsc/wasm"; const controller = new AbortController(); const { api, host } = await bootTtsc({ wasmUrl: "/compiler/ttsc.wasm", wasmExecUrl: "/compiler/wasm_exec.js", apiName: "ttsc", signal: controller.signal, }); host.writeFile( "/work/tsconfig.json", JSON.stringify({ compilerOptions: { strict: true, target: "ESNext", module: "ESNext", moduleResolution: "Bundler", }, include: ["src"], }), ); host.writeFile("/work/src/index.ts", "export const value: number = 1;"); const raw = await api.check({ cwd: "/work", tsconfig: "tsconfig.json" }); const checked = parseResult<ITtscCompileResult>(raw); postMessage({ ok: raw.code === 0, diagnostics: checked?.diagnostics ?? [], stderr: raw.stderr, });

There is no boot deadline. How long a fetch and instantiation take belongs to the network and the machine, not to a number chosen here, and a boot cancelled on that number is a failure the page cannot retry into success. Pass a signal to end a boot deliberately: aborting any caller that joined the same in-flight boot cancels that shared attempt. A rejection before go.run clears its cache entry and readiness callbacks so a later call can retry.

importScripts is synchronous. bootTtsc checks cancellation immediately before and after it but cannot interrupt script evaluation. JavaScript also cannot terminate a Go runtime after go.run starts. A failure from that point throws BootTtscWorkerTerminationError with code TTSC_WASM_WORKER_TERMINATION_REQUIRED; later boots for the same apiName reject without exposing another readiness bridge. Terminate and replace that Worker before retrying.

api.build returns the same compile payload plus an output map keyed by project-relative file names:

const raw = await api.build({ cwd: "/work" }); const built = parseResult<ITtscCompileResult>(raw); const js = built?.output["src/index.js"] ?? built?.output["index.js"];

For the base endpoints, raw.result is a JSON string. Use parseResult<T> at the Worker boundary instead of parsing the same payload repeatedly in your UI.

MemFS rules

  • Use absolute virtual paths for files and cwd, for example /work.
  • tsconfig is relative to cwd and defaults to tsconfig.json.
  • host.writeFile creates parent directories automatically; host.mkdirp exists when you need to create directories without writing a file. The low-level fs.open never does: with O_CREAT it requires the parent directory to exist already and fails with ENOENT otherwise.
  • host.writeFile copies Uint8Array input, and host.readFile returns a copy. Call writeFile again when you want to replace stored bytes.
  • A file never replaces a directory. host.writeFile and fs.open with O_TRUNC throw or return EISDIR for a directory target โ€” including / โ€” and ENOTDIR when an ancestor of the path is a file, so a conflicting file map is reported instead of stranding the directoryโ€™s descendants.
  • host.readFileText reads emitted or transformed virtual files if your plugin writes into the MemFS.
  • host.stdout.buffer and host.stderr.buffer capture direct writes to fd 1 and fd 2. Call host.resetStdio() when you intentionally inspect those buffers across separate runs.
  • The filesystem is memory-only. Recreate the project tree from UI state before each compile, or serialize calls that mutate the same paths.
  • Mutations enforce POSIX-style invariants: renaming a directory moves its whole subtree (and follows open descriptors), rmdir requires an empty directory, unlink refuses directories, and truncate/ftruncate resize file bytes โ€” zero-filling any growth โ€” or reject an invalid path, descriptor, or length. A mutation that cannot satisfy its contract returns an error instead of partially changing the tree, and a rejected fs.open allocates no descriptor.
  • Descriptors carry the access mode and O_APPEND flag they were opened with. A read-only descriptor rejects writes and a write-only descriptor rejects reads with EBADF, O_CREAT | O_EXCL on an existing path returns EEXIST, and O_DIRECTORY on a regular file returns ENOTDIR. Directories open read-only, which is what Goโ€™s os.ReadDir needs.
  • Writes follow Nodeโ€™s offset rules: position: null writes at the descriptor cursor and advances it, an explicit position overwrites there without moving the cursor, a write past end-of-file zero-fills the gap, and an O_APPEND descriptor always writes at the end. An unknown or closed descriptor fails with EBADF and changes nothing โ€” only fd 1 and fd 2 reach the capture buffers.
  • Symlinks and hardlinks are intentionally unsupported. Treat the project as a flat virtual workspace.
  • fs.pipe2 is a JavaScript-only shim. Goโ€™s wasm os.Pipe() returns ENOSYS under the pinned toolchain without crossing the globalThis.fs bridge, so a custom Go/wasm host cannot reach it; wasm output is captured through fd 1 and fd 2 and through the Go-side host.InvokePlugin invocation buffers.

The ttsc.dev playground writes one /work project per request, serializes all compile/lint/bundle calls, and reuses a single booted wasm for the whole Worker lifetime.

API surface

MethodUse it for
api.version()Build metadata for support panels and bug reports.
api.plugins()Names registered into a custom wasm by host.Config. Empty for the base binary.
api.check({ cwd, tsconfig })Type-check without emit.
api.build({ cwd, tsconfig })Type-check and emit JavaScript/declaration output into the result payload.
api.transform({ cwd, tsconfig })Return the TypeScript source-file map the program saw, keyed by project-relative path.
api.plugin({ name, command, ...opts })Dispatch a plugin linked into a custom wasm.

Every async endpoint returns:

interface ITtscResult { code: number; stdout: string; stderr: string; result: string; }

For check, build, and transform, result contains JSON. For api.plugin, plugin output usually sits in stdout or stderr, matching the native sidecar CLI. Exit code 0 means success; 2 covers compiler errors, usage errors, or bad project configuration; 3 is a runtime or emit failure.

Build a custom plugin wasm

The base binary does not link or auto-run plugins. A plugin playground builds a new wasm binary that imports your plugin package and registers it with host.Expose.

//go:build js && wasm package main import ( "github.com/samchon/ttsc/packages/wasm/host" yourplugin "example.com/your-plugin" ) func main() { host.Expose("yourApi", host.Config{ Plugins: []host.Plugin{ yourplugin.New(), }, }) }

Build it with the Go wasm target and serve the matching Go runtime loader:

GOOS=js GOARCH=wasm go build -trimpath -ldflags "-s -w" \ -o public/compiler/your-plugin.wasm ./cmd/your-wasm cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" public/compiler/wasm_exec.js

Keep wasm_exec.js from the same Go toolchain that built the wasm. The prebuilt dist/ttsc.wasm and dist/wasm_exec.js pair already match each other; custom binaries should copy the loader from their own toolchain. Older Go installs may keep the loader under $(go env GOROOT)/misc/wasm/wasm_exec.js instead of lib/wasm.

Boot the custom binary with the same API name:

const { api, host } = await bootTtsc({ wasmUrl: "/compiler/your-plugin.wasm", wasmExecUrl: "/compiler/wasm_exec.js", apiName: "yourApi", }); host.writeFile("/work/tsconfig.json", JSON.stringify(tsconfig)); host.writeFile("/work/src/index.ts", source); const raw = await api.plugin({ name: "example-plugin", command: "transform", cwd: "/work", tsconfig: "tsconfig.json", output: "ts", });

The host translates extra JavaScript options into CLI-shaped flags before it calls your Go plugin:

JavaScript optionGo args entry
{ cwd: "/work" }--cwd=/work
{ output: "ts" }--output=ts
{ quiet: true }--quiet
{ limit: 20 }--limit=20

name and command are consumed by the host and are not forwarded. Boolean false values are omitted.

Implement host.Plugin

Inside wasm there is no subprocess boundary, so plugins cannot be launched as external binaries. Instead, each bundled plugin implements:

type Plugin interface { Name() string Run(invocation *PluginInvocation) int }

The method should forward to the same command dispatcher your native sidecar uses. The first-party playground does this for @ttsc/banner, @ttsc/paths, @ttsc/strip, @ttsc/lint, and typia:

  • Name() returns the id the Worker passes to api.plugin.
  • invocation.Command and invocation.Args carry the requested verb and CLI-shaped flags.
  • Write to invocation.Stdout and invocation.Stderr; those request-owned streams become JavaScript stdout and stderr without replacing process globals.
  • Return codes should mirror the native CLI: 0 success, 2 usage or diagnostics, 3 runtime failure.

For utility-backed plugins, call utility.RunBuildWithIO(invocation.Args, invocation.Stdout, invocation.Stderr) (or the matching check/transform function). Use invocation.Go when a child goroutine contributes to the result. The host accepts registrations only while Run is active, waits for every registered child, then closes the streams. An unregistered goroutine that writes after completion receives io.ErrClosedPipe and cannot contaminate another request.

For a real reference, read the playground wasm sources:

The important pattern is the ttsc.dev typia lane: call api.plugin to run the plugin transform, parse its stdout, write the transformed TypeScript back into MemFS, then call api.build for the final JavaScript preview.

Version stamping

Custom binaries can expose useful build metadata through api.version() by overriding the host package variables at link time:

GOOS=js GOARCH=wasm go build -trimpath \ -ldflags "-s -w \ -X github.com/samchon/ttsc/packages/wasm/host.version=0.1.0 \ -X github.com/samchon/ttsc/packages/wasm/host.commit=$(git rev-parse --short HEAD) \ -X github.com/samchon/ttsc/packages/wasm/host.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ -o public/compiler/your-plugin.wasm ./cmd/your-wasm

api.version() returns the stamped version, commit, date, Go version, GOOS, and GOARCH. Surface this in bug reports; wasm playground issues are otherwise hard to reproduce.

Serving and boot pitfalls

SymptomFix
Go is not definedwasm_exec.js was not loaded. Check wasmExecUrl; the default is the same directory as wasmUrl.
importScripts is not definedThe worker was emitted as a module worker or the code ran on the main thread. Use a classic Worker-style bundle.
global was not setapiName does not match the name passed to host.Expose, or the wasm panicked before registering.
Boot rejects with exited before signaling readinessThe Go host exited or panicked before calling Ready, often because of a host.Expose misconfiguration such as duplicate plugin names. Check the wasm stderr, then terminate the Worker before retrying.
Boot throws BootTtscWorkerTerminationErrorgo.run started before boot failed. Terminate and replace the Worker. The old Go runtime cannot be interrupted, and bootTtsc refuses to expose a new readiness bridge to it.
failed to fetchThe .wasm URL is wrong, blocked by CORS, or not copied into the static output.
WebAssembly.instantiateStreaming failsServe .wasm with Content-Type: application/wasm.
The page locks upBooted wasm on the main thread. Move it into a Worker.
ENOENT for tsconfig.jsonWrite /work/tsconfig.json and every included source file before calling the API.
Plugin output is emptyFor custom plugins, write to invocation.Stdout/invocation.Stderr or return data through the virtual filesystem.
Two wasm binaries conflictGive each binary a unique apiName. Use separate Workers when they need independent fs/process globals or filesystems.

Published tarball layout

The npm package also acts as a Go module for plugin authors. The published tarball includes the host/, cmd/, and build/ Go sources, plus a rewritten go.mod that points TypeScript-Go shim replacements at ./shim-vendor/....

The tarball intentionally does not carry the in-repo replace github.com/samchon/ttsc/packages/ttsc => ../ttsc directive. If you build a custom wasm from node_modules/@ttsc/wasm, provide your own replace for github.com/samchon/ttsc/packages/ttsc or vendor that module in your workspace. Runtime-only users of dist/ttsc.wasm do not need this.

See also

Last updated on