Integrating causl-client into a TypeScript / Node.js app Enterprise

The Node-integration counterpart to causl-wasm's producer-side scripts/README.md. That file documents how a Python pipeline builds and places the wasm artefact; this file documents how a TS/Node app consumes the placed artefact and binds it through the @causl/core/wasm thin TS API.

This guide is the consumer-side concretion of SPEC §18A.4 (the thin-TS-API definition) and §18A.2 (the Node-target requirement). It covers, in order:

  1. What causl-client is — the thin TS API over the wasm core, and what it is not.
  2. Installing and using it in a TS/Node app — including the preload + synchronous construction pattern (§18A.12).
  3. Where the wasm artefact comes from — the producer/consumer split and the causl-wasm Python scripts.
  4. The node:fs loader and host-tier matrix.
  5. The read()-identity migration — the one breaking change you must audit for before a real-Rust build.
  6. The performance ceiling — what the wasm path costs and what it does not buy you today.

Every load-bearing claim cites a SPEC § anchor. Where the shipped code and the SPEC contract differ — they do, in two named places — the gap is called out as Shipped today vs Planned (§…), never blurred.


1. What causl-client is

causl is a reactive dependency-graph engine with denotational, glitch-free, transactional semantics. Its public contract is the §12 surface: the seven-method spine

input · derived · commit · read · subscribe · snapshot · explain

plus the structural queries dependencies / dependents (and their transitive closures, the commit log, commit metadata, handle/disposal validation, stats — the §12.2 second-tier surface).

Per SPEC §18A.1, that contract ships in two conformant engines, held byte-identical at the §12 boundary by the cross-backend determinism gate (§18A.1.1):

EngineWhat it isStatus
causl-ts The TypeScript reference engine — the value-of-record running natively on the JS event loop. The unconditional floor (§13.8). Lives in causljs/causl-ts-wasm-engine.
causl-wasm The Rust core (engine-rs-core + engine-rs-bridge) compiled to WebAssembly, reached over FFI. The unconditional production engine in causl-client (rust-ssot is the default); the §18A.3 FFI lift landed (causl-wasm#170) so every adopter op resolves from Rust. Lives in causljs/causl-wasm.

causl-client is the thin TypeScript API over the causl-wasm core (§18A.4). Stated plainly so the boundary stays honest, it is not:

It is a thin adapter that (1) implements the public Graph interface by delegating each method to the wasm core over FFI; (2) marshals parameters across the JS↔WASM boundary (the cost is measured, not hidden — §6 below); and (3) binds to the placed .wasm artefact. It ships no Rust source and no build tooling — the produce-and-place tooling is the engine repo's (causljs/causl-wasm, §18A.11); the consume-and-bind surface is this repo's.

Repository topology (§18A.10)

causljs/causl-wasm Rust engine + FFI externs + Python build/package tooling (the source of truth for the causl-wasm engine) causljs/causl-client THIS REPO — the thin TS API + Node loader + adoption docs (consumes the placed .wasm; ships no Rust) causljs/causl-ts-wasm-engine causl-ts (the TS floor) + cross-backend bench/conformance harness

Named first-party integration consumers: iasbuilt/xldatagrid and iasbuilt/webapp (Node.js TS web apps).

Current state of the wasm path (SPEC §18A.3 / §18A.5). The WasmBackend returned by loadWasmBackend() is a real Rust engine (engine-rs-core), not a TS wrapper: the §18A.3 FFI structural lift has landed (causl-wasm#170), so every adopter operation resolves from Rust — engine orchestration (commit pipeline, dependency tracking, cutoff, history) runs in Rust, while the user's derived() compute lambdas run in JS over the bridge callback by design. The FFI seam, the bridge picker, and the cross-backend byte-identity gate are stable and enforced. Importing the wasm entry point is the unconditional production path; the standing JS↔WASM marshal cost is named honestly (§17.6 G.1), within the §14 RAIL budget — not a "beats-TS" claim. This is repeated at the top of packages/core/wasm/README.md.
causl-client → wasm-default with a TS capability-fallback (SPEC §18A.13 + §18A.13.1; shipped). By a recorded governance decision, causl-client ships the wasm engine as its sole default and public engine (rust-ssot is the default) and has un-exported the pure-TypeScript factory (createCauslTs) from the public @causl/core barrel. The driver is complexity-elimination — one engine, one codepath — with perf explicitly accepted as immaterial here (it deliberately bypasses the §18A.7 GO/NO-GO perf gate and says so plainly). §18A.13.1 (2026-06-23) retains createCauslTs internally and wires it as the implicit createCausl() path's WasmGC-unavailable capability fallback: on a host where WasmGC cannot instantiate, the implicit createCausl() degrades loudly (console.warn + telemetry, never silent) to the TS floor, while explicit createCauslWasm() / engine: 'rust-ssot' still fail loud (CAUSL_WASM_ENGINE_UNAVAILABLE). So this is not strictly "wasm-only," and deleting createCauslTs outright is dropped from near-term scope. (The WasmGC probe itself is still a placeholder, #691.) The cut was executed wire-before-cut: the §18A.12 sync seam (below) landed first so createCausl() routes to the wasm engine synchronously at every call site (post-preload), and the public TS factory was then un-exported (epic causl-client#31). The §18A.3 FFI structural lift has since landed (causl-wasm#170), so every adopter operation resolves from Rust; the internal TS engine is retained (not deleted) as the §12 reference and the §18A.13.1 capability fallback. Scope: the cut is causl-client only. The fork that hosts the §18A.1.1 differential oracle and the benchmark suite (causljs/causl-ts-wasm-engine) deliberately keeps the dual-engine TS floor as its DEFAULT (DEFAULT_WASM_ENGINE_MODE = 'js-ssot') and adds a real-Rust differential leg as the byte-identity oracle — it does not flip to rust-ssot.

2. Installing and using it

2.1 Install

pnpm add @causl/core # or: npm i @causl/core / yarn add @causl/core

The wasm path is an opt-in subpath. Importing @causl/core pulls in only the TS engine and a tiny loader stub (~1 KB); the wasm bundle cost is paid only by callers who explicitly import('@causl/core/wasm'). Node 22+ is required (engines.node: ">=22").

2.2 The smallest worked example (TS engine — the floor)

Identical to SPEC §10. Two inputs, one derived, one diamond, one subscriber, two commits, three observed propagations:

import { createCausl } from '@causl/core' const graph = createCausl() // backend: 'js' is the default const a = graph.input('a', 1) const b = graph.input('b', 2) const sum = graph.derived('sum', (get) => get(a) + get(b)) const sumPlusOne = graph.derived('sumPlusOne', (get) => get(sum) + 1) graph.subscribe(sumPlusOne, (v) => console.log(v)) // 4 graph.commit('bump-a', (tx) => tx.set(a, 10)) // 13 graph.commit('bump-both', (tx) => { tx.set(a, 100) tx.set(b, 200) }) // 301 — exactly one notification, not two (glitch-free)

All mutation happens inside commit; outside, the graph is read-only (§12, commitment 2). The single notification on the second commit is glitch-freedom as a theorem, not a scheduler trick (§3 Theorem 2).

2.3 Opting into the wasm engine

There are two adopter-facing paths. Pick based on whether you want WASM unconditionally or only when a workload heuristic trips.

Path A — drive the wasm backend directly (loadWasmBackend()). This is the canonical path for "I want the wasm engine on this graph." loadWasmBackend() returns a BackendEngine — the third actor at the BackendEngine seam (packages/core/src/backend.ts), alongside JsBackend (the TS engine) and WasmBackend (the Rust core). Always wrap it in the documented fallback:

import { createCausl } from '@causl/core' import { loadWasmBackend, WasmBackendUnavailableError } from '@causl/core/wasm' import type { BackendEngine } from '@causl/core/wasm' async function makeEngine(): Promise<{ kind: 'wasm' | 'js' }> { try { const backend: BackendEngine = await loadWasmBackend() // … wire `backend` as the graph's engine seam (see §2.4 note) … return { kind: 'wasm' } } catch (err) { if (err instanceof WasmBackendUnavailableError) { // Host can't run the chosen bridge (CSP block, missing WasmGC, // pinned-but-unsupported bridge, fetch failure). Fall back to the // TS floor — it runs on any host that runs JavaScript. return { kind: 'js' } } throw err } }

WasmBackendUnavailableError carries a structured code field so you can dispatch per failure mode (CAUSL_WASM_NOT_BUILT, CAUSL_WASM_CSP_BLOCKED, …); the five codes and the per-code dispatch are documented in docs/wasm-adoption-guide.md §3. The loader does not auto-fall-back — you decide.

Path B — let the engine choose (backend: 'auto'). Start on the TS engine and migrate to the wasm backend at runtime when the auto-adapt heuristic trips (graph size, derivation depth, subscriber count). The migration is one-way (a transient spike cannot ping-pong the selection):

import { createCausl } from '@causl/core' const graph = createCausl({ backend: 'auto' }) // runs on the TS engine until the heuristic trips, then transparently // migrates the same graph onto the wasm backend.
Accuracy note — the createCausl backend option. In causl-client (wasm-default), the production path is the wasm engine reached through preloadCauslWasm() + the sync createCausl() / createCauslWasmSync() route (§18A.13) — createCausl resolves to the wasm engine post-preload, no per-call backend string needed. The historical backend: 'js' | 'auto' string option and the 'auto' auto-adapt heuristic live on the fork's dual-engine distribution; 'wasm' was never a synchronous constructor string because loading the artefact requires the import('@causl/core/wasm') + loadWasmBackend()/preload seam. SPEC §18A.4/§18A.1 describe the seam as "backend: await loadWasmBackend() passed to createCausl"; on the wasm-default distribution that seam is now the default, reached via the preload + sync split below.

2.4 Switch engines at the seam, never in user code

The whole point of the byte-identity gate is that the two engines are interchangeable at the public-contract boundary. Your model code — input / derived / commit / read / subscribe — is identical regardless of which engine is behind it. The 'js''wasm' choice lives at the BackendEngine seam (packages/core/src/backend.ts) and nowhere else. If a piece of application logic has to know which engine it's running on, that is a bug — the only legitimate exception is the read()-identity migration (§5), which you fix once, defensively, so it is correct under both engines.

2.5 Synchronous wasm construction — preload once, build sync (§18A.12)

The two paths above reach the wasm engine through an await at the construction site (await loadWasmBackend(), or the one-call await createCauslWasm() convenience). For a synchronous consumer — a React hook or render body, an SSR pass, xldatagrid's imperative grid API — an await at the build site is exactly where it hurts. SPEC §18A.12 splits the one unavoidable async (compiling the WebAssembly.Module) out of construction so the construction site stays synchronous. This is shipped — in the fork (causljs/causl-ts-wasm-engine, the authoritative loader source) and in causl-client.

The factory trio. Engine choice is explicit at the call site when you want it — there is no silent runtime flip:

FactoryEngineSync?Subpath
createCauslTs() The pure-TypeScript reference engine — the floor that runs anywhere JS runs. sync @causl/core
createCauslWasm() / createCauslWasmSync() The wasm engine (Rust→WebAssembly core, reached through the consolidated causl-engine-bridge loader). async / sync @causl/core/wasm
createCausl() In causl-client routes to the wasm engine post-preload (§18A.13), degrading loudly to the retained internal createCauslTs() on a WasmGC-unavailable host (§18A.13.1). On the fork's dual-engine distribution it routes to createCauslTs() by default (js-ssot). sync @causl/core

The pattern: preload once at init, then construct synchronously. The single await lives at app/init; every render, hook, or SSR site thereafter builds a wasm graph with no await:

import { preloadCauslWasm, createCauslWasmSync } from '@causl/core/wasm' // ONCE, at app/init — the only async seam. Compiles + caches the // WebAssembly.Module (plus the _bg.js sidecar and the compute-imports // snippet), keyed by bridge. Idempotent: concurrent calls share one // compile; a transient failure drops the cache entry for retry. await preloadCauslWasm() // Thereafter, at every render / hook / SSR site — FULLY synchronous. // `new WebAssembly.Instance` from the cached Module, zero await. const graph = createCauslWasmSync()

Node vs browser (cross-ref §18A.2). On Node / SSR the --target nodejs glue is already synchronous end-to-end (readFileSync + new WebAssembly.Module + new WebAssembly.Instance at require/import time), so createCauslWasmSync() works without a prior preload — the server render stays synchronous on its own. It is the browser bundler target that needs the one-time await preloadCauslWasm() to complete before the first render that constructs a wasm graph; this ordering is what keeps SSR↔CSR hydration parity (no engine constructed mid-render in an unresolved-promise state). An app that skips the preload does not silently block — it throws (or, with fallbackToTs, degrades to the floor).

Atomicity is untouched (cross-ref §4.3 / §18A.6 / §3 Theorem 2). The compile await is hoisted to bootstrap, outside any commit envelope; createCauslWasmSync performs no await between the §5 Phase E and Phase G boundaries. The single-tick invariant of §4.3 holds exactly as before — moving the one async to app init does not put an await anywhere near a commit.

3. The wasm artefact placement (the producer/consumer split)

causl-client consumes a placed .wasm artefact. It does not build one. Per §18A.11, the build-and-place tooling is two stdlib-only Python scripts in causljs/causl-wasm — the producer side — and a webapp's CI/CD pipeline drives them. The split:

SideRepoArtefactTool
Producer causljs/causl-wasm builds + places the .wasm scripts/build_wasm.py, scripts/package_wasm.py
Consumer causljs/causl-client (you) binds the placed .wasm over FFI the @causl/core/wasm loader

3.1 Producer side — the Python scripts (run by your pipeline)

These need a Rust toolchain (cargo + wasm-pack + wasm-opt); your consuming pipeline does not — it only needs CPython stdlib to run package_wasm.py. The full contract is in causl-wasm's scripts/README.md.

# Build + place in one call — the common pipeline entry point. # --dest states WHERE the artefact goes; the consumer picks the path. python3 scripts/build_wasm.py --bridge gc-classic # → build/wasm-nodejs python3 scripts/package_wasm.py --build --dest /path/to/app/src/engine/wasm

build_wasm.py produces a size-optimised, node-target (--target nodejs) .wasm plus the node glue (causl_engine_bridge.js) and .d.ts typings. package_wasm.py copies that artefact set into --dest and writes a deterministic manifest (causl-wasm.manifest.json, schema causl-wasm/manifest@1) recording the engine version, bridge, target, wasm-opt flags, and a per-file sha256. It is stdlib-only, makes no network calls, and fails loud (non-zero exit, clear stderr) if --src is incomplete, a file can't be hashed, or --dest is unwritable.

The same scripts also place the compute-imports snippet next to the .wasm / _bg.js and assert fail-loud at packaging time on three §18A.12 invariants, so a wasm-pack / wasm-bindgen upgrade that breaks the synchronous-construction seam (§2.5) never ships silently:

3.2 Consumer side — vendor + verify the manifest

In your app's CI/CD, after package_wasm.py has placed the artefact, verify it against the manifest before the build proceeds. This closes the "producer ships, consumer places" loop honestly:

# verify-wasm-manifest.py — a loud post-place gate for your pipeline. import hashlib, json, pathlib, sys dest = pathlib.Path("src/engine/wasm") manifest = json.loads((dest / "causl-wasm.manifest.json").read_text()) for f in manifest["files"]: digest = hashlib.sha256((dest / f["name"]).read_bytes()).hexdigest() if digest != f["sha256"]: sys.exit(f"checksum mismatch for {f['name']}") print(f"wasm artefact verified: {manifest['engineVersion']} ({manifest['bridge']})")

Git-track the manifest so rebuilds are deterministic and a drifted artefact fails CI loudly rather than shipping silently.

Build targets. The .wasm artefacts vendored in this repo under packages/core/wasm-pkg/gc-classic-bundler/ and gc-builtins-bundler/ are --target bundler builds (webpack 5 / Vite 5 / esbuild via asset-pipeline loaders) for the browser path. The --target nodejs artefact + the node:fs loader hook is the §18A.2 requirement (Criterion 4) and is the Node production shape this guide and the causl-wasm scripts target. On the Node path the loader resolves the placed artefact via node:fs with no async load shape; the browser path uses the bundler-resolved new URL(..., import.meta.url) shape (§4.2). When you wire a pure-Node service with the --target nodejs artefact, consume it as the causl-wasm scripts/README.md shows (synchronous require/import of the node glue) and bind it through the BackendEngine seam.

4. The loader and the host-tier matrix

4.1 The host-tier bridges (§17.6)

The bridge picker (detectBridge()) probes the host at module load and selects the most-capable artefact it actually supports; a host that lacks the higher tier falls back automatically, and the TS engine is the universal floor below all of them. The two shipped bridge variants of the consolidated causl-engine-bridge crate:

Bridge idString strategyWhen picked
wasmgc-builtins js-string-builtins — no-copy wasm:js-string Hosts with wasm:js-string host bindings (Node 22.6+, Chrome 131+, Firefox 130+). The fastest tier.
wasmgc-classic classic-strings — UTF-16 fallback The universal WasmGC baseline; needs no wasm:js-string host support, so it instantiates on every WasmGC-capable host. The safe default detectBridge() returns.

Pin a bridge to skip detection cost when you know your target:

const backend = await loadWasmBackend({ bridge: 'wasmgc-classic' })

4.2 The loader resolution shape

Shipped today (bundler target). The loader resolves the artefact via new URL('./pkg/<segment>/causl_engine_bridge_bg.wasm', import.meta.url) — the lowest common denominator across webpack 5 (experiments.asyncWebAssembly), Vite 5 (vite-plugin-wasm), esbuild 0.20+ (--loader:.wasm=file), and Node 22+ ESM. Override the base URL for a CSP connect-src / CDN scenario:

const backend = await loadWasmBackend({ wasmBaseUrl: 'https://cdn.jsdelivr.net/npm/@causl/core@<version>/wasm/pkg/', })

Node target (§18A.2). The --target nodejs glue instantiates the wasm synchronously at require/import time, so the Node loader hook resolves the placed artefact via node:fs with no async load shape — it works identically in CJS and ESM. This is the loader the causl-wasm scripts/README.md "Consuming the packaged artefact in Node" section pairs with, and it satisfies the §18A.2 requirement. The browser bundler path above is the parallel shape for the Vite / webpack target.

4.3 The FFI single-tick invariant — never await the commit entry (§18A.6)

The FFI commit entry point (apply_commands / the Rust commit_batch extern) is a synchronous #[wasm_bindgen] call. The async surface you await is loading the backend (await loadWasmBackend()), not committing through it. A commit is one atomic tick (§3 Theorem 2 / §5 Phase A–H "no intermediate time"). Do not await a commit — interleaving an await mid-commit would break single-tick atomicity silently. graph.commit(intent, tx => …) is and stays synchronous on both engines.


5. The read()-identity migration (§18A.5 / §15.1 #1124)

This is the one breaking change the wasm path introduces, and the one thing you must audit for when consuming the Rust engine.

The contract. graph.read(node) is not contractually required to return the same JavaScript reference across calls. Value identity at a fixed GraphTime is guaranteed; reference identity across commits is not (§15.1, ratified by the #1124 amendment, shipped with 0.9.0).

Why it bites. With the §18A.3 FFI lift landed (causl-wasm#170), a wasm-backed read() returns a fresh object per call — the value is deserialised across the FFI boundary on each read. Any adopter who keys memoisation on the read() return reference re-renders every commit, silently. No error fires — memoise on commit.time / node-version instead (§5).

The fix — key on commit.time or a per-node version, not on read() identity.

// WRONG — breaks at the real-Rust swap. The `read()` reference changes // every commit, so this memo invalidates every commit (or, worse, // never updates if the reference is reused trivially today). import { useMemo } from 'react' function UserCard({ user }: { user: User /* a read() return */ }) { const transformed = useMemo(() => transform(user), [user]) // ← reference key // ... } // RIGHT — keys on commit.time (the GraphTime on the published Commit), // which advances by exactly one per commit and is byte-identical under // both engines. import { useMemo } from 'react' function UserCard({ user }: { user: User }) { const commit = useCauslCommit() // commit.time: GraphTime const transformed = useMemo(() => transform(user), [commit.time, user]) // ... }

For workloads where commit.time is too coarse (it advances on every commit, even ones that don't touch your node), key on the per-node version counter instead — the read_derived_version extern surfaced through EngineTelemetry:

const telemetry = useEngineTelemetry() const version = telemetry.nodeVersion(node) // bumps only when `node` changes const transformed = useMemo(() => expensiveTransform(value), [version])

Pre-migration checklist (required reading before a real-engine upgrade, §18A.5). Audit, across your codebase:

Migrate each to commit.time (GraphTime on the Commit) or the per-node version counter. A dev-only hazard warning is available behind createCausl({ enableH1HazardWarning: true }) — it records each long-held read() return as a WeakRef and emits one console.warn per survivor whose read-time GraphTime predates the post-commit clock (opt-in; off by default; dead-code-eliminated in production builds). The right-vs-wrong example also lives in docs/wasm-adoption-guide.md §H1.


6. The performance ceiling (§18A.7)

Honesty about cost is a contract, not a footnote. State it in front of every wasm-vs-js decision.

The perf ceiling (SPEC §18A.7 Criterion 3). For the wasm path to be a promotion candidate, the JS↔WASM marshal overhead must hold to:

These are the marshal-overhead bars the cross-backend benchmark gates against — the cost of crossing the boundary, separate from the engine's own work.

The standing wire tax. Before the engine does any work there is a documented crossing cost: the 78× wire tax — ~156.4 ms for 10k commits across the boundary vs ~2.017 ms TS median. This is the floor that batching (epic #1493) can amortise the crossing portion of (down toward the ≤50 ns/op floor at large afterN), but it does not amortise the engine-exec cost. At current WASM runtime maturity (no GC GA, limited JIT, no SIMD) the Rust-engine-in-WASM per-commit execution cost is ~85× the TS engine — a property of today's runtime, not of the engine design, and one batching provably cannot remove.

What this means for you, concretely:

Promotion was governance, not a CI flip — and it happened. The wasm engine becomes the WASM-path default only when all five §18A.7 GO/NO-GO criteria pass — byte-identity (1), full FFI surface (2), the perf ceiling above (3), the Node target + a real-Rust production adopter (4), and the §18A.7 governance criterion (5) — recorded by a dated amendment. That amendment landed on 2026-06-21 (causl-wasm#169): all five are GO, rust-ssot is the unconditional default, and the per-flush byte-compare oracle + sticky-downgrade fail-safe were removed (§18A.8). The TS engine stays the unconditional floor — retained in causl-client as the §12 reference and the §18A.13.1 implicit-path capability fallback — but it is no longer the default.


See also