@macula-io/ts
v0.16.0
Published
TypeScript SDK for the Macula mesh (FFI over macula-go)
Maintainers
Readme
macula-ts
Status, 2026-09-04: identity generation, a real transport + CONNECT/HELLO handshake, unary RPC (both roles), DHT record lookups/publication, pubsub, content transfer, UCAN minting/inspection
- UCAN-gated calling, and direct-dial (both roles) are all live-verified against the real production fleet (
station-de-frankfurt.macula.io). Streaming RPC, streaming/content direct-dial, cert-chain-authorized direct-dial, and provider-side UCAN policy gating don't exist yet — see What's explicitly not yet implemented. Published to npm as@macula-io/tswith zero install-time scripts — see Packaging. Full development history, including every bug found and fixed along the way, lives in CHANGELOG.md, not here.
What is this?
A TypeScript SDK for the Macula mesh protocol — real QUIC-based mesh connectivity from Node.js: identity, sessions, unary RPC, pub/sub, content transfer, UCAN capability tokens, and direct-dial, all reaching the real production fleet today. Built as an FFI binding over macula-go rather than a native reimplementation — see below for why.
Why FFI over macula-go, not a native TypeScript reimplementation
Macula's mesh protocol runs over raw QUIC with a custom ALPN string
("macula", not "h3") and its own length-prefixed, deterministic-CBOR
frame format — not HTTP/3. Node.js has no mature first-party QUIC stack
that's actually usable for this today:
node:quic(Node's own built-in, experimental module) does work at the transport level — the QUIC+TLS 1.3 handshake with ALPN"macula"completes and a bidirectional stream opens against the real production stations. But it is absent from every currently-supportable official Node binary — compile-time gated out of the Node 24 and 26 LTS lines — and the one line that does ship it (Node 25.x) is already past its own EOL and crashes the process about a second after a successful handshake (a native assertion failure inEndpoint::FindSession). Not viable to depend on today.- No actively-maintained pure-JS or WASM QUIC implementation currently exposes a public client API with custom-ALPN support (the most promising one found, quico, documents custom ALPN only on its low-level server API — its client convenience API is HTTP/3-specific).
macula-go, macula-rust, macula-dotnet, and macula-php have all already
proven this protocol works and are actively maintained. Rather than
reimplement QUIC + deterministic CBOR + Ed25519 framing a fifth time in a
language with no mature QUIC story of its own, macula-ts reuses macula-go's
already-proven implementation through FFI — the same tradeoff
macula-php already made
successfully (see its cabi/ directory, which this package's own cabi/
is structurally modeled on, including its handle-based memory-ownership
convention).
Sibling SDKs
| Repo | Approach | |---|---| | macula | The reference SDK (Erlang/OTP) | | macula-go | Go port — same protocol | | macula-rust | Native reimplementation (quinn, pure Rust) | | macula-dotnet | Native reimplementation (System.Net.Quic / msquic) | | macula-php | FFI binding over macula-go (this package's structural precedent) | | macula-ts | FFI binding over macula-go | | macula-station | The station: DHT, SWIM, routing, peering | | macula-realm | Managed-realm identity + certificate authority |
Quick start
Also lives as a runnable example -- npm run build && node
examples/01_quickstart.ts. Advertises and calls its own trivial echo
procedure (two identities, a provider and a caller, since a station kicks
a connection the instant a second one arrives under the same identity)
rather than depending on any particular procedure already being
advertised on the fleet:
// Connects to a real macula-station, advertises a trivial echo procedure,
// and calls it. Dials the real production fleet, so this isn't run by
// CI -- see README.md's "Quick start" section, which this file backs.
// Run: npm run build && node examples/01_quickstart.ts
//
// Two identities are used (a provider and a caller) because a station
// kicks a connection the instant a second one arrives under the same
// identity.
import { Identity, Session } from "../dist/index.js";
const providerId = Identity.generate();
const callerId = Identity.generate();
const provider = await Session.connect("station-de-frankfurt.macula.io", 4433, providerId);
const caller = await Session.connect("station-de-frankfurt.macula.io", 4433, callerId);
// Unique per run -- reusing a fixed procedure name across rapid repeated
// runs can hit stale DHT routing state from the prior run's now-dead
// advertiser.
const procedure = `macula_ts.quickstart_echo.${Date.now()}`;
const stop = await provider.serve(procedure, (payload) => payload);
await new Promise((resolve) => setTimeout(resolve, 500)); // ADVERTISE is fire-and-forget; give it a moment to land
const response = await caller.call(procedure, "hello");
console.log("call response:", response);
await stop();
await provider.close(providerId);
await caller.close(callerId);
providerId.dispose();
callerId.dispose();
console.log("OK");Architecture
src/*.ts --(node-gyp-build)--> addon/binding.cc (N-API) --(static link)--> cabi/ --(cgo)--> macula-gocabi/ is a Go module that imports macula-go and builds with
go build -buildmode=c-archive into a C ABI static archive (libmacula.a
libmacula.h) — not a shared library.addon/binding.ccis a small, purpose-built node-addon-api N-API addon (not a generic FFI bridge) that linkslibmacula.ain statically, so the resulting.nodefile is self-contained: nothing to locate ordlopenat runtime, no separate shared library to ship alongside it.src/binding.tsloads that addon vianode-gyp-build(a zero-dependency runtime loader) and re-exports its typed functions;src/identity.ts(and everything built on top of it) is the actual public TypeScript API, never touching the addon directly.
Memory ownership, copied from macula-php's cabi/ rather than
reinvented: every opaque Go value (an identity keypair, a session, or an
inbound "pending call" awaiting a serve() handler's reply) crosses the
boundary as a uintptr_t from runtime/cgo.Handle. Hold it, pass it
back for every operation on that value, and free it exactly once
(Identity#dispose() on the TS side; a pending-call handle is freed
automatically by whichever of macula_pending_call_reply_result/_error
answers it). Fixed-length fields (a 32-byte NodeID or seed) are written
directly into a caller-supplied output buffer. Every exported function
resolves handles through a recover()-guarded lookup, never a raw
cgo.Handle(h) — cgo.Handle's own .Value()/.Delete() panic, not
return an error, on a handle this process never issued or already freed.
RPC payloads cross this boundary as JSON text, not another handle —
cabi/wirevalue.go converts to/from macula-go's cbor.Value, ported from
macula-cli's
internal/wirevalue package (already proven against the same no-bool
rule) rather than reinvented, plus a reserved {"$bytes": base64} object
for bytes (see the RPC caller role below). Session.serve()
cannot hand a Go closure across the FFI boundary the way ServeOneCall
expects, since the actual answer has to come from arbitrary, possibly-
async TypeScript — so cabi/serve.go splits that one blocking Go call
into three cgo exports instead (wait-for-call, read the pending call's
procedure/payload, reply), the same split
macula-php's cabi/serve.go
already proved for the identical problem.
Development history (every bug found while building this, and how it was fixed) is in CHANGELOG.md.
What's implemented
Identity —
Identity.generate()(a fresh, S/Kademlia puzzle-hardened Ed25519 identity),Identity.fromSeedBytes()(deterministic reconstruction from a saved 32-byte seed),identity.nodeId/.privateSeedBytes/.dispose(), andidentity.sign(data)— a generic Ed25519 primitive (no application-specific message format baked in;datais signed exactly as given). Using a disposedIdentitythrows instead of signing with a freed handle.Session —
Session.connect(host, port, identity)dials a real macula-station and completes the CONNECT/HELLO handshake (WebPKI trust).session.remoteAddr,session.stationNodeId(the HELLO-verified station identity),session.close(identity, reason?)(idempotent). Using a session's accessors afterclose()throws cleanly rather than crashing.RPC, caller role —
session.call(procedure, payload, opts?)sends a signed CALL and waits for the matching RESULT/ERROR.payload/the return value areJsonValue(string/number/null/array/ object — no boolean, since macula's wire CBOR has no bool type; encodetrue/falseas1/0yourself). Bytes go in as an object whose only key is$bytes, holding standard padded base64:{"$bytes": "AQID"}is the bytes01 02 03. Any other value under that sole key is an error, an object with more keys stays a map, and a plain string is always text. Bytes come back as"0x"-prefixed hex by default;opts.bytes: "tagged"returns them in the same$bytesform, so a returned id can be passed straight back. A BOLT#4 ERROR frame (e.g.unknown_next_peer) rejects with aMaculaCallErrorcarrying the numericcode,bolt4Name,retryable, anddetail.opts.realm(a 64-character hex string) scopes the call to a realm other than the all-zero default;callWithUcan()/publish()/subscribe()take the identical option.RPC, provider role —
session.serve(procedure, handler, opts?)advertisesprocedureand answers inbound CALLs against it forever, invokinghandler(payload)for each (sync or async;opts.bytesas forcall()). Resolves with an asyncstop()that unadvertises and waits for the current poll tick to finish. Only oneserve()perSessionat a time, andcall()/serve()refuse to run concurrently on the sameSession— both read frames off one shared control stream; open a secondSessionfor the other role.DHT —
session.findRecordsByType(recordType),session.findRecords(key),session.findRecord(key), andsession.putProcedureAdvertisement(procedure, servingStation, opts?)/session.putContentAnnouncement(mcid, endpoint, ttlMs?)— typed builders wrapping macula-go's owndht.NewProcedureAdvertisement/NewContentAnnouncement(signed viadht.Sign, stored viadht.PutRecord). There is deliberately no genericputRecord(type, arbitraryPayload)— a procedure_advertisement/ content_announcement payload carries raw pubkey/MCID fields that must be actual CBOR byte strings, which only the typed builders guarantee.Pubsub —
session.publish(topic, payload, opts?)(fire-and-forget, no ack on the wire) andsession.subscribe(topic, handler, opts?)(opts.bytesas forcall()).subscribe()resolves with an asyncstop()that sends UNSUBSCRIBE and does not resolve until the underlying reader goroutine has genuinely exited. Only onesubscribe()(and no activeserve()) perSessionat a time —publish()itself is exempt, since it only ever writes, so aSessioncan safelypublish()on the same topic it'ssubscribe()d to.Content transfer —
session.putContent(data, name?)/session.getContent(mcid), sent on their own dedicated QUIC stream (not the shared control stream, so they run safely alongside an activeserve()/subscribe()). Data above 256 KiB is chunked and reassembled automatically.mcidcrosses the boundary as a lowercase hex string. This is a one-time TRANSFER mechanism, not durable object storage — a station may forget content after serving it, and there is no list/delete operation.UCAN —
Ucan.mint(issuer, audience, capabilities?, opts?)(a JWT-shaped, EdDSA-signed capability token, UCAN spec"0.10.0") andUcan.decode(token)(parses claims WITHOUT verifying signature or expiry —Ucan#isExpiredmirrors macula-go's own semantics). Both are pure local operations, no network I/O.issueris written asdid:macula:<hex NodeID>andaudienceas the audience NodeID in lowercase hex.session.callWithUcan(procedure, payload, ucanToken, opts?)attaches a token to an outgoing CALL, for invoking a procedure gated behind a provider-sideucan.Policy.Requiredpolicy. A gated provider accepts a token only from the caller itsaudnames, so mint it for the identity that will present it;callWithUcanattaches whatever token it is given. This SDK does not exposeucan.Verifyorucan.Policy— only minting, inspecting, and attaching a token are implemented; enforcing one is provider-side, out of scope here.Direct-dial —
session.resolveDirect(procedure, opts?),session.callDirect(procedure, payload, opts?),session.callDirectWithUcan(procedure, payload, ucanToken, opts?)(caller side) andsession.advertiseDirect(procedure, opts?)plus a standalonekeepAdvertisedDirect(session, procedure, opts?)helper (provider side). Resolves a signedprocedure_advertisementDHT record to its serving station's own signedstation_endpoint, then dials that station directly in one hop instead of depending on advertise-gossip having reached whichever station the caller happens to already be connected to. Every advertisement that verifies is a candidate:resolveDirect()asks the DHT again until one's station endpoint resolves, withinopts.deadlineMs(10 s when unset), andcallDirect()moves on to the next candidate when a dial fails, within its owndeadlineMs. Trust is enforced at the application layer: the freshly connected peer's HELLO-proven identity is checked against the exact pubkey the signed DHT chain resolved.advertiseDirect()issues both a plain ADVERTISE and the signed DHT record on the same call — both are required forresolveDirect()+callDirect()to actually reach a live route.resolveDirect/callDirect/callDirectWithUcan/advertiseDirectshare the same same-Session exclusivity guard ascall()/the DHT methods; a long-lived provider that also serves the same procedure needs a separateSession(and identity — this fleet enforces one connection per identity) to keep re-advertising on, which is whykeepAdvertisedDirect()is a standalone function rather than aSessionmethod.Pool —
Pool.connect(seeds, controlIdentity, opts)holds live connections to every configured seed concurrently (not dial-one-then-fallback-on-failure), each independently monitored and respawned with backoff on disconnect;publish()/call()fan out over live links,subscribe()re-establishes automatically on reconnect;call()andsubscribe()take the samebytesoption. Portsmacula/src/client/macula_client.erl's pool design; seepool.ts's own module doc for why it's a set of role-scopedSessions per seed rather than one, given the single-reader constraint below.
Every item above is live-verified against the real production fleet
(station-de-frankfurt.macula.io), including negative/error paths and,
where applicable, the actual packaged npm tarball rather than only the
dev build — see CHANGELOG.md for the specific
assertions, bugs found, and fixes for each.
What's explicitly not yet implemented
Streaming RPC, streaming/content direct-dial (OpenStreamDirect,
PutDirect/GetDirect — plain Session.call/serve direct-dial is
implemented, see above), cert-chain-authorized direct-dial
(ResolveWithCertChain/CallWithCertChain/AdvertiseDirectWithCertChain
— opt-in even in macula-go itself), provider-side UCAN policy gating
(ucan.Policy/ServeOneCallGated — this SDK can mint/attach a token but
not enforce one on a served procedure), per-realm serve/advertise
(these two still only ever use the all-zero realm — call/callWithUcan/
publish/subscribe, and DHT's putProcedureAdvertisement, all DO now
take an optional realm), a generic "put any DHT record type with an
arbitrary payload" function (see above for why), a station_endpoint
record builder (macula-go has none either — stations publish those
themselves, not clients), and Pinned/Insecure trust modes (WebPKI
only so far). Multiple concurrent subscribe() topics on one Session
still isn't supported at the Session level itself — one subscribe()
(like one serve()) per Session at a time; open a second Session
for a second topic (Pool, above, does exactly this internally to give
each tracked topic its own session). Each of these is a separate, later
slice of work built on top of a working Session.
Testing
npx vitest run # default suite, no network
npm run test:live # MACULA_TS_LIVE=1 vitest run src/session.live.test.ts src/rpc.live.test.ts src/dht.live.test.ts src/pubsub.live.test.ts src/content.live.test.ts src/ucan.live.test.ts src/directdial.live.test.ts src/pool.live.test.tssrc/session.live.test.ts, src/rpc.live.test.ts, src/dht.live.test.ts,
src/pubsub.live.test.ts, src/content.live.test.ts,
src/ucan.live.test.ts, src/directdial.live.test.ts, and
src/pool.live.test.ts hit the real
production fleet and are not part of default npm test/CI — opt in
explicitly, gated behind MACULA_TS_LIVE. Same convention as macula-go's
live build tag, macula-rust's #[ignore], and macula-dotnet's
[Trait("Category","Live")]: real-network tests are written and
runnable, just excluded from the default/CI run so a station outage doesn't
make ordinary CI flaky. .github/workflows/ci.yml exposes this as a
manually-triggered (workflow_dispatch) job, never run automatically on
push/PR.
Packaging: genuinely zero install-time scripts
An earlier version of this package used koffi (a
generic dynamic FFI bridge) to load libmacula.so at runtime. That was
replaced — koffi has its own native install script and ships no
prebuilt binaries in its npm tarball, so it inherited the exact class of
npm-install-script friction that
macula-mcp's better-sqlite3 dependency caused
before that project moved to node:sqlite. No actively-maintained
generic Node FFI library was found that avoids this.
Instead, addon/binding.cc is a small addon purpose-built for exactly
macula-ts's own exported functions (not a generic bridge), packaged with
prebuildify +
node-gyp-build — the same
pattern used by sharp, bcrypt, and other native modules that need zero
consumer-side compilation. The compiled .node binary for each supported
platform is baked into prebuilds/ and published as part of the npm
package itself (not gitignored — there is nothing to build or fetch
at a consumer's npm install time). package.json has no install,
postinstall, or preinstall script at all.
Five platforms are covered: linux-x64, linux-arm64, darwin-arm64,
darwin-x64, and win32-x64. .github/workflows/prebuilds.yml builds
each on a real GitHub-hosted runner for that platform (CGO_ENABLED=1
needs a matching native C toolchain per target, so cross-compiling
cabi/'s Go archive from Linux isn't the right approach here — the same
reason sharp/bcrypt/etc. use real per-OS runners) and commits the
results back to main. .github/workflows/ci.yml's "Confirm the
committed prebuild is not stale" step re-verifies, on every push, that
linux-x64's committed binary still matches a fresh rebuild of current
source, byte for byte. Getting that check — and the Windows build — to
actually hold surfaced three real build-toolchain bugs; see
CHANGELOG.md for the specifics.
Development
npm run build:go # builds cabi/build/libmacula.a -- must run BEFORE
# npm install, since binding.gyp's mere presence in
# this repo (not in the published package) makes npm
# implicitly run `node-gyp rebuild` as part of
# install, and that rebuild links against this archive
npm install # builds the native addon (via the implicit node-gyp
# rebuild above) and installs JS deps
npm run typecheck
npm test
npm run build:prebuilds # regenerate prebuilds/ after touching addon/ or cabi/ -- commit the result
npm run build # local dev build: addon + tscRequires Go >=1.27 (for cabi/), a C++ toolchain (for addon/), and Node
=24.18.1 (see
enginesinpackage.json— matches the same floor macula-mcp landed on fornode:sqlite; earlier Node lines don't ship it). None of this is required to consume the published package — only to work on macula-ts itself.
License
Apache-2.0
