TypeScript SDK for the Macula mesh, via FFI over macula-go
Status, 2026-10-05: on the macula 12 wire (post-quantum: ML-DSA-87
identities, as the ML-DSA-87 + RSA-PSS-4096 composite in pq_hybrid, the
fleet's profile; key exchange on SecP384r1MLKEM1024 alone, ML-KEM-1024 with
P-384, which meets CNSA 2.0 and BSI TR-02102; signed requests), over macula-go's
pool. Calls and streams by direct dial, serving (under an org or in a node's
own namespace), publish/subscribe, the DHT and node-served content are
tested against in-process macula 12 stations on every npm test, as are
UCAN-gated calls, streams and serving. Releases before 0.18.0 speak the
retired 10.x wire and cannot reach the current fleet.
What is this?
A TypeScript SDK for the Macula mesh: a node's key, a pool of links to
stations it pins by node_id, calls and streams that reach a provider by direct
dial, serving procedures, publish/subscribe and the DHT, from Node.js. 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 in Endpoint::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, TLS 1.3 with a hybrid post-quantum key exchange, deterministic CBOR and signed frames a fifth time in a
language with no mature QUIC story of its own, macula-ts reuses macula-go's
already-proven implementation through macula-go's shared C ABI
(cabi/macula.h,
contract in cabi/CONTRACT.md), the one C ABI every non-Go SDK binds.
A node needs a station to link to, pinned by its node_id, and the key of
each realm it trusts, which the realm publishes. Its own key is created on
first use and kept in a file readable by its owner only.
import { NodeKey, Pool, StreamMode } from"@macula-io/ts";
const key = awaitNodeKey.loadOrCreate("node.key");
const pool = awaitPool.connect(key, [{ host: "2600:3c0e::2000:c2ff:fed0:f20b", port: 4433, nodeId: stationId }], {
realmTrust: [{ realm, key: realmKeyHex }],
});
// A call reaches a provider by direct dial: its advertisement from the DHT,// trusted only when the realm key authorizes it, and its station dialed.const answer = await pool.call(realm, "mcl-echo/echo", "hello");
// A payload this node's key vouches for (ownership proof v2, mcl-om#7): the// provider refuses it changed, for another procedure or realm, or sent twice.const proven = await key.ownershipProof(realm, "mcl-graph/learn_link", { subject: "a", predicate: "knows", object: "b" });
await pool.call(realm, "mcl-graph/learn_link", proven);
// Publish and subscribe; topics name a kind of fact, ids go in the payload.const sub = await pool.subscribe(realm, "acme/demo/greeting_sent_v1", (e) =>console.log(e.payload));
await pool.publish(realm, "acme/demo/greeting_sent_v1", { text: "hi" });
// Serve in this node's own namespace, ~<node_id>/ring: no org, no realm key.const served = await pool.serve(realm, pool.ownProcedure("ring"), (r) => ({ answered: r.caller }));
// Streams: a server stream's chunks arrive until its end.const stream = await pool.openStream(realm, "mcl-tube/watch", StreamMode.Server);
forawait (const event of stream) if (event.kind === "end") break;
await stream.free();
await pool.close();
Everything moved to the macula 12 wire, and the API with it. There is no
compatibility layer.
New identities. A macula 12 node_id derives from an ML-DSA-87 key (or the
LAMPS composite in pq_hybrid), so no Ed25519 identity carries over.
NodeKey.loadOrCreate(path) makes a new key file; your old seed files are
left untouched. Re-join your realms and re-trust your agents: anything
that named your old node_id (trust lists, petnames, realm memberships) must
be redone with the new one.
Identity is now NodeKey; Session and Pool are one Pool, whose seeds
carry the station's nodeId and whose realmTrust pins realm keys;
callDirect is simply call; resolveDirect is providers.
Serving an org procedure needs the realm's org directory and the org's
delegation to your node in the DHT: a realm admits orgs through a human.
Architecture
src/ (TypeScript API) ── addon/binding.cc (N-API) ── macula-go cabi (C ABI 1, c-archive) ── macula-go pool
The addon links macula-go's shared C ABI, built as a c-archive from the
macula-go release in native/MACULA_GO (scripts/build-native.sh, which
takes macula-go through go mod download and the Go checksum database and
checks the release's commit). There is no Go code in this repository. Every Go
value crosses as a handle; payloads cross as JSON with no booleans and bytes
as {"$bytes": "<base64>"} both ways (this package turns them into "0x"
hex by default, or leaves them tagged with bytes: "tagged"); an error
crosses as JSON with a fixed kind, which becomes ProviderError,
RelayError, NotSharedError, ContentUnavailableError or a MaculaError
carrying the kind. Every call that does network I/O runs on a worker thread
(Napi::AsyncWorker) and returns a Promise. The ABI never calls back into
Node: each subscription and served procedure has a thread of its own that
takes from its inbox, with a cancel token of its own, and hands each event,
call and session to JavaScript through a ThreadSafeFunction.
What's implemented
Primitive
Caller
Provider
Notes
Node keys (NodeKey)
✅
✅
pq_hybrid (the fleet's) or pq_pure; key files readable by the owner only; sign and NodeKey.verify, pq_hybrid checked against the LAMPS draft's own vector and cross-verified with macula 12.7.0
Signed objects (verifySignedObject)
✅
macula's {key, tbs, signature}, verified under a label and profile to the signer's node id (derived from the verified key) and the signed fields; one that does not verify throws UnverifiedError with its reason. Checked against the RAG service contract's frozen vector, both profiles
Pool of station links (Pool.connect)
✅
✅
Seeds pinned by node_id; realm keys pinned; links redialed with subscriptions and served procedures replayed
Calls by direct dial (call, providers)
✅
✅
serve: a thrown error goes back as handler_error; errors arrive as ProviderError / RelayError
A node's own namespace (ownProcedure)
✅
✅
~<node_id>/<name>: served and called with no org and no realm key; the node's signature authorizes it
Streams (openStream, serveStream)
✅
✅
Server, client and bidi; a QUIC stream per session, released on every path
Sealed calls and streams (confidential, kemAdvertise)
✅
✅
macula 13's E2E seal scheme 1 through macula-go v0.18.0: a call or stream is sealed to the provider's advertised KEM key whenever its advertisement names one ("preferred", the default), "required" never calls a provider that names none, and what could not be kept confidential is a ConfidentialityError with its reason. A provider names its key only with kemAdvertise: 1 (off by default: turn it on once every station runs macula 12.11 or later and its callers run macula 13, macula-go 0.18 or this release); it serves "preferred" (with kemAdvertise, clear calls are taken only while its last keyless advertisement could still be served, then refused sealed_required), "required" or "off", and each request says sealed: 0 | 1. A provider that cannot open a sealed call twice answers a ProviderError of code sealed_refused, its detail the key id it holds now (hex, or empty when it holds none). Pool.callReport (a call's result with its seal report) and Stream.report() tell a caller afterwards whether the exchange went sealed, to which provider and key (sealed: 0 | 1, provider, sealKeyId; macula-go v0.19.0). The report says sealing ran on that exchange, nothing more
UCANs (NodeKey.ucan, Ucan, ucan/proofs, policy)
✅
✅
macula 12's D7 through macula-go v0.17.0's C ABI: NodeKey.ucan(audience, caps, expS, {prf}) mints a token for the node that presents it (caps a list of {with, can}, each with an MRI), and Ucan.proofId names a delegated token's parent. call, callReport and openStream present ucan with proofs, its chain's parents; an empty token, or proofs without one, is refused before anything is sent. serve and serveStream take policy: Ucan.ucanRequired(issuer) (a chain rooted at that node's identity key) or Ucan.realmMemberRequired(keyId, can) (rooted at that realm key, granting that can). libmacula checks each call and open before it reaches the handler: a refused call is a ProviderError, a refused open a StreamError, of code unauthorized (malformed_frame for a proof no token names). Tested against in-process stations: no token, a token for another node and one from another root refused; a direct grant and a one-level delegation served
macula-realm#29: a join session over HTTP ("http", the realm's JSON rule) or a membership UCAN over the mesh ("mesh"), with its device_info and ttl_seconds, the realm and the procedure signed, and a nonce; the realm's own vector reproduced through macula-go's encoder
Ownership proof v2 (NodeKey.ownershipProof)
✅
—
mcl-om#7: the payload's asserted_by, binding every field a handler reads, the procedure, the realm, a timestamp and a nonce; a payload carrying caller is refused; mcl_om's own vector reproduced, and a payload signed here and delivered through a station accepted by mcl_om 0.32.0's verify_asserted_by
macula 12.6.0 (D27): shared on the node's own ~<node_id>/content_v1 and announced; a fetch checks the block, the manifest and every chunk against the content id, bounded, with no realm key; NotSharedError / ContentUnavailableError
Testing
npm test# builds native/build (libmacula.a and the teststation), then the offline suite
npm run test:live # one live station, see below
npm test runs src/pool.test.ts against macula-go's own
teststation/cmd/teststation at the pinned release, a helper that runs two
in-process macula 12 stations sharing
a DHT, with a test realm that admits the test's provider nodes. It exercises
keys, calls by direct dial and their errors, providers, server and client
streams (and that no stream is left unreleased), pubsub and the DHT, through
the real addon. No network is needed.
src/lamps.test.ts holds pq_hybrid, the LAMPS composite
id-MLDSA87-RSA4096-PSS-SHA512, to draft-ietf-lamps-pq-composite-sigs' own
vector (the one macula and macula-go check), and to composites that crossed
both ways with macula 12.x. scripts/cross-verify-macula.sh renews those: this
SDK signs, macula (from hex, in the image macula's own CI runs in) verifies
and signs its own, and this SDK verifies it.
src/ownershipproof.test.ts holds ownership proof v2 to mcl_om 0.32.0's own
vector (testdata/ownership_proof_vector, copied from macula-go at the pinned
release): the bytes it signs, and an Erlang key's signature over them.
src/fleet.live.test.ts runs against one real station and is not part of
npm test. It needs MACULA_TS_LIVE_SEED (host:port), MACULA_TS_LIVE_STATION_ID
(the station's node_id), MACULA_TS_LIVE_REALM and MACULA_TS_LIVE_REALM_KEY;
an unset one fails the run naming it. It reads the DHT, calls mcl-echo/echo
by direct dial and hears its own publication.
.github/workflows/live.yml runs it when dispatched by hand, on an addon it
builds from the commit's source.
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 in the npm package's prebuilds/ (there is nothing to build or
fetch at a consumer's npm install). package.json has no install,
postinstall, preinstall or prepublishOnly script at all.
Five platforms are covered: linux-x64, linux-arm64, darwin-arm64,
darwin-x64, and win32-x64. On Linux and macOS the prebuild is one .node
with macula-go's cabi linked in statically; on Windows it is the .node and
macula-go's DLL beside it (macula-<release>.dll, named after macula-go's
release so two versions of this package never share one), because node-gyp links with MSVC, which
does not start the Go runtime of a MinGW-built static archive. .github/workflows/prebuild-matrix.yml builds
each from source on a real GitHub-hosted runner for that platform (cgo needs
the platform's own C toolchain, so no cross-compiling), loads it and generates
a key with it before uploading it. The Linux prebuilds are built on Ubuntu 22.04
(glibc 2.35, libstdc++ GLIBCXX_3.4.30) and also loaded in Debian 12
(node:24-bookworm), so they run on Debian 12, Ubuntu 22.04 and anything newer. The release workflow attests each prebuild
(build provenance) and packs all five into the package it publishes; CI runs
the same matrix on every push, then installs the packed package into an empty
project and checks the install compiled nothing. Prebuilds are never committed
(macula-ts#2).
Development
npm run build:go # builds native/build (libmacula.a, macula.h, teststation)# from macula-go at native/MACULA_GO -- 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# builds native/build first
npm run build:prebuilds # this platform's prebuild, into prebuilds/ (never committed)
npm run build # local dev build: addon + tsc
Requires Go >=1.27 and a C compiler (to build macula-go's cabi), a C++
toolchain (for addon/), and Node
=24.18.1 (see engines in package.json — matches the same floor
macula-mcp landed on for node: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.
TypeScript SDK for the Macula mesh, over macula-go's shared C ABI
The npm package @macula-io/ts receives a total of 448 weekly downloads. As such, @macula-io/ts popularity was classified as not popular.
We found that @macula-io/ts demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago.It has 1 open source maintainer collaborating on the project.
Cursor removed a malicious Claude Code 2.1.313 extension from its marketplace. Socket examines registry records and the initial claims about its distribution.