@siltrun/room-host
v0.1.1
Published
Silt Bun contract runtime — the deterministic realm behind fd 3. Loads a room contract bundle, runs its tick() authoritatively, and speaks the length-prefixed JSON protocol to the Go relay.
Readme
@siltrun/room-host
The Bun contract runtime for Silt compute rooms. Loads a room
contract bundle, one file default-exporting { init?, tick }, and runs it
authoritatively inside a deterministic realm, speaking the framed protocol to the Go
relay on fd 3.
You do not usually install this yourself. The siltrun CLI depends on it and resolves
it for you; siltrun dev is the front door. Reach for it directly when you are
embedding the runtime or running the determinism doctor by hand.
Run
npm i @siltrun/room-host
bun host.ts <contract-bundle.js> [--seed <n>] # seed also via SILT_ROOM_SEED
bun doctor.ts <contract-bundle.js> <ticks> [--seed <n>] # determinism doctorProtocol (pinned)
AF_UNIX SOCK_STREAM socketpair, child end at fd 3, 4-byte big-endian length
prefix + JSON. fd 3 must be a BLOCKING fd (Go's syscall.Socketpair fds are;
the host reads with blocking readSync — a non-blocking fd breaks the read loop
with EAGAIN). stdout/stderr are the contract dev's logs, never protocol.
{type:"restore", tick, state|null}→ establishes canonical state (null= fresh:init()runs, or the first tick's default param seeds) and replies{type:"ready"}. Ready is the reply to restore: the relay sends restore immediately after spawn and ticks only after ready.{type:"tick", tick, inputs}→{type:"state", tick, state, emits}or{type:"error", tick, message, stack}. A throwing tick is skipped — last good state holds. One frame in flight, ever.
emits: the contract calls ctx.emit(event); events collect into the reply
frame's emits array.
The deterministic realm
Installed before the contract import (realm.ts):
Math.sin/cos/atan2/exp/log/pow/hypot→dmath.mjs, a vendored bit-reproducible math kernel (provenance header in the file — re-copy, don't edit).Math.random→hash(roomSeed, tick, drawIndex)stream, one draw counter shared withctx.random()— re-executing a tick replays the identical sequence.Date.now/new Date()/performance.now→ sim time (tick / 60s).Math.tan/asin/acos/…(unproven surface) → throw with a fix hint.- Tick-phase gate:
fetch, timers (setTimeout/setInterval/setImmediate/ queueMicrotask/process.nextTick+ clears),crypto.getRandomValues/randomUUID/ subtle,Bun.spawn/file/write/$/connect/listen/serve/sleep/nanoseconds/…, andprocess.hrtime/uptimethrow while a tick is executing (naming the determinism rule) and pass through outside it — soinitkeeps the full runtime while the tick realm stays closed. The separation is temporal:tickis synchronous and single-in-flight, soRuntime.tickOncebrackets it exactly. Capturing a reference at module/init scope doesn't escape the gate — the realm installs before the contract import, and the check happens at call time. - State handed to
tickis a freshstructuredClone; the return is validated plain-serializable (state.ts) — Map/Set/class/typed-array/undefined-holes/ NaN/circular all throw naming the exact field path.
Enforcement level — honest: this is a global-swap + phase-gate realm, not
full realm isolation. Everything reachable through a global — including module
imports that mirror one (import { hrtime } from "node:process" resolves to the
gated wrapper, because the swap precedes the contract import) — is enforced.
Still outside the fence: modules whose capability has no global counterpart
hand the contract the real thing — node:fs (no global at all), node:crypto's
classic API (randomBytes/…), node:child_process, node:net; and some mirrors
diverge per engine (node:timers exports un-gated functions on node, gated ones
on bun). The Bun namespace object itself is non-configurable/non-writable, so
only its known members are gated. The doctor is the backstop past that line: a
contract pulling entropy from node:crypto's randomBytes gets drift reported at
tick 1 rather than passing silently. The upgrade path is to run the contract in a
dedicated context with a locked module registry (node:vm, a Worker, or a microVM),
which does not change the protocol, ctx, or state contracts.
The determinism doctor
doctor.ts replays a seeded scripted input sequence (replay.ts, same
Realm+Runtime path as production) and compares per-tick FNV-1a hashes of
canonical state JSON across (1) two fresh processes on this engine and (2)
bun vs node when node is on PATH. On drift it reports the first drifted tick
and the first differing field path. replay.ts and its imports deliberately
avoid non-erasable TS syntax and bun-only APIs so node can type-strip them.
Layout
The package is plain TypeScript source with zero runtime dependencies:
host.ts— CLI +Runtime(engine, transport-free) +serve(fd-3 loop)realm.ts— the deterministic realm;protocol.ts— framed blocking IOstate.ts— plain-state validator;hash.ts— FNV-1a + draw streamreplay.ts/doctor.ts— the doctor pair;dmath.mjs— vendored math
Docs
Full guides and API reference: https://silt.run/docs/
