tsgo-wasm 0.3.0

typescript-go (tsgo) compiled to WASI p1, with an optional wasmtime runtime
docs.rs failed to build tsgo-wasm-0.3.0
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.

tsgo-wasm

microsoft/typescript-go (tsgo) compiled to a WASI p1 module, embedded in a Rust crate with an optional wasmtime runtime. Maintained by GoRules.

Usage

[dependencies]
tsgo-wasm = "0.1"

Each crate version pins an exact typescript-go revision (see TSGO_REV and the CHANGELOG); bumping the crate switches the module. The wasm binary itself is fetched from this repo's release assets at build time — see Module distribution.

use std::time::Duration;
use tsgo_wasm::TypeScript;

let ts = TypeScript::new()?;
let diagnostics = ts.check(
    &[("main.ts", "const n: number = 'x';\nexport default n;\n")],
    Duration::from_secs(30),
)?;
assert!(diagnostics.iter().any(|d| d.code == 2322));

Everything is in-memory: sources go in as (path, content) pairs, diagnostics come out as structured values, and the guest sees a virtual filesystem served from a HashMap via tsgo's filesystem callbacks — no host filesystem is involved on any OS.

  • TypeScript::new() compiles the embedded module eagerly (~2s wall / ~20s CPU, parallelized). Construct it at process startup so the cost lands on boot, not on the first check; the Module is reused across runs at full speed.
  • TypeScript::with_cache(path) is a dev-loop convenience: it deserializes a previously cached compilation (~10ms) and falls back to compile-and-cache, so frequent process restarts (cargo watch) skip the boot compile. The cache is keyed to the wasmtime version/config by wasmtime itself; a mismatch silently recompiles.
  • check(&[(path, content)], timeout) runs one full project check (a tsconfig.json among the sources is honored; {} is synthesized otherwise) and returns all diagnostics. It is shorthand for a throwaway ApiSession — for repeated checks, hold a session instead.
  • Timeouts use epoch interruption (100ms granularity by default), safe under concurrent sessions; a timed-out session is killed, not leaked.

Persistent sessions (ApiSession)

For repeated checks, drive tsgo's built-in API server (tsgo --api) as a persistent in-memory session — sources live in a HashMap and are served to the guest via tsgo's filesystem callbacks; nothing ever touches any filesystem, on any OS:

let mut session = ts.api_session(
    &[
        ("lib/user.ts", "export interface User { id: number }\n"),
        ("main.ts", "import { User } from './lib/user';\nconst u: User = { id: 1 };\nexport default u;\n"),
    ],
    Duration::from_secs(60),
)?;

let diagnostics = session.diagnostics()?;
session.update_file("main.ts", "")?;
let diagnostics = session.diagnostics()?;
  • The session boots once (~130ms) and stays warm; update_file/remove_file produce incremental snapshots. Measured on an M-series Mac: a one-shot check costs ~920ms, while update_file + diagnostics_for inside a session costs ~1.5ms (~600x).
  • Use diagnostics_for(file) (syntactic + semantic for one file) as the hot path after edits; diagnostics() aggregates config-parsing, syntactic, semantic, global, and program diagnostics project-wide and re-checks everything including the default libs (~0.5-0.9s).
  • A tsconfig.json is synthesized ({}) unless you provide one among the sources.
  • Each Diagnostic carries the file name (as you named it), a typed Category (is_error()), the tsc code, pos/end UTF-16 offsets plus a resolved 1-based line/column Range (computed from your in-memory sources), and recursive message_chain / related_information diagnostics.
  • The wire protocol is tsgo's synchronous MessagePack-framed API over guest stdio, chosen deliberately: WASI p1 stdin is blocking, and the sync protocol's inline request→callbacks→response cycle is exactly compatible with Go's single-threaded wasip1 scheduler.

Custom engine config

Embedders with non-default requirements use TypeScriptConfig — every path (load, cache, precompile, cwasm) flows through the same config value, so AOT artifacts and the loading engine match by construction:

use tsgo_wasm::TypeScriptConfig;

let config = TypeScriptConfig {
    signals_based_traps: false,
    memory_limit: Some(2 * 1024 * 1024 * 1024),
    epoch_tick: Duration::from_millis(10),
    ..Default::default()
};
let ts = config.load()?;
  • signals_based_traps: false is required when wasmtime shares a process with a runtime that owns the signal handlers (V8 in a Node addon, JVM). It switches to explicit bounds checks: ~4x slower compilation, larger code, slower execution — leave it true (default) in pure Rust processes, where guest faults already surface as clean Err traps.
  • memory_limit caps each run's linear memory via a store limiter.
  • TypeScript::new() / with_cache / from_cwasm and the build_cwasm* helpers are shorthands for the same methods on TypeScriptConfig::default().
  • A cwasm only loads into an engine whose compile-relevant config matches — precompile and load with the same TypeScriptConfig value.

Build-time AOT

For consumers where the ~13 CPU-seconds boot compile is unacceptable (Lambda, tightly CPU-limited pods), precompile in your own build script — version, target, and engine config stay matched by construction, including cross-compilation:

[build-dependencies]
tsgo-wasm = "0.1"
fn main() {
    tsgo_wasm::build_cwasm().unwrap();
}
static TSGO_CWASM: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/tsgo.cwasm"));
let ts = unsafe { TypeScript::from_cwasm(TSGO_CWASM)? };

build_cwasm_zst(level) writes tsgo.cwasm.zst instead (~17 MB at level 19 vs 92 MB raw) — from_cwasm detects zstd transparently, trading ~92 MB of binary size for a short decompression at startup.

Costs: +92 MB in the binary (or the compressed size with the zst variant) and ~13 CPU-seconds per fresh build. In dev profiles build dependencies default to opt-level 0, which makes the precompile step several times slower — add [profile.dev.build-override] opt-level = 2 if you AOT in dev builds. precompile(target) returns the raw cwasm bytes if you'd rather ship it as a file. Embeddings with their own wasmtime engine (different version or config) must not use these helpers; feed module_bytes() to their own Engine::precompile_module instead.

Bytes only

[dependencies]
tsgo-wasm = { version = "0.1", default-features = false }

Exposes TSGO_WASM_ZSTD, TSGO_REV, and module_bytes() with only a zstd dependency. The module is runtime-agnostic wasm + WASI p1: it runs under wasmtime, wasmer, Node's node:wasi, or any other wasip1 host. Works on any target for embedding; executing it requires a native host runtime (the runtime feature does not build on wasm32-*).

Performance

Measured on an M-series Mac, 5.1k-line type-check: native tsgo 0.13s (multi-threaded); wasm 0.9–1.0s in wasmtime and V8 alike (wasip1 is single-threaded and Go's wasm codegen is ~4x slower per core). The sandboxing and portability are the point; use native tsgo where you control the input and platform.

Updating tsgo

Every published crate version is immutably fixed to one typescript-go commit: tsgo.rev and tsgo.sha256 are frozen into the crates.io package at publish time, and tsgo updates always land as a new minor (0.1.x0.2.0), which cargo treats as an incompatible range — consumers never receive a new tsgo without an explicit version bump.

Updating is therefore a release act: run the Update tsgo workflow (workflow_dispatch) with the desired microsoft/typescript-go ref. It builds the module, runs the tests against it, caches the built module keyed by rev, and pushes the updated artifacts/tsgo.rev + tsgo.sha256 to main as a feat: commit. That push feeds release-please, which maintains the Release PR (version bump + CHANGELOG); merging that tags v<version> — the release workflow then attaches tsgo.wasm.zst to the GitHub release and publishes the crate to crates.io.

Locally, make tsgo TSGO_REV=<ref> (requires Go and zstd) produces the same artifacts for development.

Module distribution

The wasm binary is never committed to git or packaged into the crate — the repo and crates.io package carry only its rev and sha256. Every v<version> GitHub release carries the tsgo.wasm.zst its crate version was built from. build.rs resolves the module in order:

  1. TSGO_WASM_FILE=<path> env override (offline / vendored / air-gapped builds)
  2. artifacts/tsgo.wasm.zst in the source tree, if its sha256 matches (present after make tsgo; repo CI restores it from an actions cache keyed by rev, rebuilding on a miss)
  3. Download from this repo's v<version> release asset, verified against the committed sha256

The result lands in OUT_DIR and is embedded via include_bytes!, so the consumer API is identical in all three paths. TSGO_REV (from the committed artifacts/tsgo.rev) always records the exact upstream commit.

About GoRules

tsgo-wasm is built and maintained by GoRules. We use it to type-check TypeScript inside sandboxed environments across our platform — if you're evaluating decision automation, check out our open-source Business Rules Engine, a high-performance rules engine with a visual decision modeler, available for Rust, NodeJS, Python, Go, Java and .NET.