@macula-io/ts 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +106 -272
  2. package/dist/binding.d.ts +50 -45
  3. package/dist/binding.js +8 -125
  4. package/dist/binding.js.map +1 -1
  5. package/dist/content.d.ts +32 -13
  6. package/dist/content.js +43 -41
  7. package/dist/content.js.map +1 -1
  8. package/dist/index.d.ts +5 -9
  9. package/dist/index.js +6 -8
  10. package/dist/index.js.map +1 -1
  11. package/dist/key.d.ts +31 -0
  12. package/dist/key.js +72 -0
  13. package/dist/key.js.map +1 -0
  14. package/dist/pool.d.ts +185 -131
  15. package/dist/pool.js +223 -576
  16. package/dist/pool.js.map +1 -1
  17. package/dist/stream.d.ts +63 -0
  18. package/dist/stream.js +93 -0
  19. package/dist/stream.js.map +1 -0
  20. package/dist/wire.d.ts +53 -0
  21. package/dist/wire.js +79 -0
  22. package/dist/wire.js.map +1 -0
  23. package/package.json +7 -5
  24. package/prebuilds/darwin-arm64/@macula-io+ts.node +0 -0
  25. package/prebuilds/darwin-x64/@macula-io+ts.node +0 -0
  26. package/prebuilds/linux-arm64/@macula-io+ts.node +0 -0
  27. package/prebuilds/linux-x64/@macula-io+ts.node +0 -0
  28. package/prebuilds/win32-x64/@macula-io+ts.node +0 -0
  29. package/dist/dht.d.ts +0 -55
  30. package/dist/dht.js +0 -25
  31. package/dist/dht.js.map +0 -1
  32. package/dist/directdial.d.ts +0 -79
  33. package/dist/directdial.js +0 -76
  34. package/dist/directdial.js.map +0 -1
  35. package/dist/identity.d.ts +0 -43
  36. package/dist/identity.js +0 -83
  37. package/dist/identity.js.map +0 -1
  38. package/dist/pubsub.d.ts +0 -60
  39. package/dist/pubsub.js +0 -8
  40. package/dist/pubsub.js.map +0 -1
  41. package/dist/rpc.d.ts +0 -86
  42. package/dist/rpc.js +0 -57
  43. package/dist/rpc.js.map +0 -1
  44. package/dist/session.d.ts +0 -481
  45. package/dist/session.js +0 -810
  46. package/dist/session.js.map +0 -1
  47. package/dist/ucan.d.ts +0 -110
  48. package/dist/ucan.js +0 -140
  49. package/dist/ucan.js.map +0 -1
package/README.md CHANGED
@@ -19,28 +19,22 @@
19
19
 
20
20
  ---
21
21
 
22
- > **Status, 2026-09-04:** identity generation, a real transport +
23
- > CONNECT/HELLO handshake, unary RPC (both roles), DHT record
24
- > lookups/publication, pubsub, content transfer, UCAN minting/inspection
25
- > + UCAN-gated calling, and direct-dial (both roles) are all
26
- > **live-verified against the real production fleet**
27
- > (`station-de-frankfurt.macula.io`). Streaming RPC, streaming/content
28
- > direct-dial, cert-chain-authorized direct-dial, and provider-side UCAN
29
- > policy gating don't exist yet — see [What's explicitly not yet
30
- > implemented](#whats-explicitly-not-yet-implemented). Published to npm
31
- > as `@macula-io/ts` with zero install-time scripts — see
32
- > [Packaging](#packaging-genuinely-zero-install-time-scripts). Full
33
- > development history, including every bug found and fixed along the
34
- > way, lives in [CHANGELOG.md](CHANGELOG.md), not here.
22
+ > **Status, 2026-09-26:** on the **macula 12** wire (post-quantum: ML-DSA-87
23
+ > identities, ML-KEM hybrid key exchange, signed requests), over macula-go's
24
+ > pool. Calls and streams by direct dial, serving (under an org or in a node's
25
+ > own namespace), publish/subscribe, the DHT and node-served content are
26
+ > tested against in-process macula 12 stations on every `npm test`.
27
+ > UCAN-gated calls are not here yet; see [Not yet
28
+ > implemented](#not-yet-implemented). Releases before 0.18.0 speak the retired
29
+ > 10.x wire and cannot reach the current fleet.
35
30
 
36
31
  ## What is this?
37
32
 
38
- A TypeScript SDK for the Macula mesh protocol — real QUIC-based mesh
39
- connectivity from Node.js: identity, sessions, unary RPC, pub/sub,
40
- content transfer, UCAN capability tokens, and direct-dial, all reaching
41
- the real production fleet today. Built as an FFI binding over
42
- [macula-go](https://github.com/macula-io/macula-go) rather than a native
43
- reimplementation — see below for why.
33
+ A TypeScript SDK for the Macula mesh: a node's key, a pool of links to
34
+ stations it pins by node_id, calls and streams that reach a provider by direct
35
+ dial, serving procedures, publish/subscribe and the DHT, from Node.js. Built as
36
+ an FFI binding over [macula-go](https://github.com/macula-io/macula-go) rather
37
+ than a native reimplementation; see below for why.
44
38
 
45
39
  ## Why FFI over macula-go, not a native TypeScript reimplementation
46
40
 
@@ -66,7 +60,7 @@ that's actually usable for this today:
66
60
 
67
61
  macula-go, macula-rust, macula-dotnet, and macula-php have all already
68
62
  proven this protocol works and are actively maintained. Rather than
69
- reimplement QUIC + deterministic CBOR + Ed25519 framing a fifth time in a
63
+ reimplement QUIC, post-quantum TLS, deterministic CBOR and signed frames a fifth time in a
70
64
  language with no mature QUIC story of its own, macula-ts reuses macula-go's
71
65
  already-proven implementation through FFI — the same tradeoff
72
66
  [macula-php](https://github.com/macula-io/macula-php) already made
@@ -89,273 +83,113 @@ convention).
89
83
 
90
84
  ## Quick start
91
85
 
92
- Also lives as a runnable example -- `npm run build && node
93
- examples/01_quickstart.ts`. Advertises and calls its own trivial echo
94
- procedure (two identities, a provider and a caller, since a station kicks
95
- a connection the instant a second one arrives under the same identity)
96
- rather than depending on any particular procedure already being
97
- advertised on the fleet:
98
-
99
- ```typescript
100
- // Connects to a real macula-station, advertises a trivial echo procedure,
101
- // and calls it. Dials the real production fleet, so this isn't run by
102
- // CI -- see README.md's "Quick start" section, which this file backs.
103
- // Run: npm run build && node examples/01_quickstart.ts
104
- //
105
- // Two identities are used (a provider and a caller) because a station
106
- // kicks a connection the instant a second one arrives under the same
107
- // identity.
108
- import { Identity, Session } from "../dist/index.js";
109
-
110
- const providerId = Identity.generate();
111
- const callerId = Identity.generate();
112
-
113
- const provider = await Session.connect("station-de-frankfurt.macula.io", 4433, providerId);
114
- const caller = await Session.connect("station-de-frankfurt.macula.io", 4433, callerId);
115
-
116
- // Unique per run -- reusing a fixed procedure name across rapid repeated
117
- // runs can hit stale DHT routing state from the prior run's now-dead
118
- // advertiser.
119
- const procedure = `macula_ts.quickstart_echo.${Date.now()}`;
120
-
121
- const stop = await provider.serve(procedure, (payload) => payload);
122
- await new Promise((resolve) => setTimeout(resolve, 500)); // ADVERTISE is fire-and-forget; give it a moment to land
123
-
124
- const response = await caller.call(procedure, "hello");
125
- console.log("call response:", response);
126
-
127
- await stop();
128
- await provider.close(providerId);
129
- await caller.close(callerId);
130
- providerId.dispose();
131
- callerId.dispose();
132
- console.log("OK");
86
+ ```bash
87
+ npm install @macula-io/ts
88
+ ```
89
+
90
+ A node needs a station to link to, **pinned by its node_id**, and the key of
91
+ each realm it trusts, which the realm publishes. Its own key is created on
92
+ first use and kept in a file readable by its owner only.
93
+
94
+ ```ts
95
+ import { NodeKey, Pool, StreamMode } from "@macula-io/ts";
96
+
97
+ const key = await NodeKey.loadOrCreate("node.key");
98
+ const pool = await Pool.connect(key, [{ host: "2600:3c0e::2000:c2ff:fed0:f20b", port: 4433, nodeId: stationId }], {
99
+ realmTrust: [{ realm, key: realmKeyHex }],
100
+ });
101
+
102
+ // A call reaches a provider by direct dial: its advertisement from the DHT,
103
+ // trusted only when the realm key authorizes it, and its station dialed.
104
+ const answer = await pool.call(realm, "mcl-echo/echo", "hello");
105
+
106
+ // Publish and subscribe; topics name a kind of fact, ids go in the payload.
107
+ const sub = await pool.subscribe(realm, "acme/demo/greeting_sent_v1", (e) => console.log(e.payload));
108
+ await pool.publish(realm, "acme/demo/greeting_sent_v1", { text: "hi" });
109
+
110
+ // Serve in this node's own namespace, ~<node_id>/ring: no org, no realm key.
111
+ const served = await pool.serve(realm, pool.ownProcedure("ring"), (r) => ({ answered: r.caller }));
112
+
113
+ // Streams: a server stream's chunks arrive until its end.
114
+ const stream = await pool.openStream(realm, "mcl-tube/watch", StreamMode.Server);
115
+ for await (const event of stream) if (event.kind === "end") break;
116
+ await stream.free();
117
+
118
+ await pool.close();
133
119
  ```
134
120
 
121
+ Runnable versions are in [`examples/`](examples).
122
+
123
+ ### Coming from 0.17 and earlier
124
+
125
+ Everything moved to the macula 12 wire, and the API with it. There is no
126
+ compatibility layer.
127
+
128
+ - **New identities.** A macula 12 node_id derives from an ML-DSA-87 key (or the
129
+ LAMPS composite in `pq_hybrid`), so no Ed25519 identity carries over.
130
+ `NodeKey.loadOrCreate(path)` makes a new key file; your old seed files are
131
+ left untouched. **Re-join your realms and re-trust your agents**: anything
132
+ that named your old node_id (trust lists, petnames, realm memberships) must
133
+ be redone with the new one.
134
+ - `Identity` is now `NodeKey`; `Session` and `Pool` are one `Pool`, whose seeds
135
+ carry the station's `nodeId` and whose `realmTrust` pins realm keys;
136
+ `callDirect` is simply `call`; `resolveDirect` is `providers`.
137
+ - Serving an org procedure needs the realm's org directory and the org's
138
+ delegation to your node in the DHT: a realm admits orgs through a human.
139
+
135
140
  ## Architecture
136
141
 
137
142
  ```
138
- src/*.ts --(node-gyp-build)--> addon/binding.cc (N-API) --(static link)--> cabi/ --(cgo)--> macula-go
143
+ src/ (TypeScript API) ── addon/binding.cc (N-API) ── cabi/ (Go, C archive) ── macula-go pool
139
144
  ```
140
145
 
141
- `cabi/` is a Go module that imports `macula-go` and builds with
142
- `go build -buildmode=c-archive` into a C ABI static archive (`libmacula.a`
143
- + `libmacula.h`) — not a shared library. `addon/binding.cc` is a small,
144
- purpose-built [node-addon-api](https://github.com/nodejs/node-addon-api)
145
- N-API addon (not a generic FFI bridge) that links `libmacula.a` in
146
- statically, so the resulting `.node` file is self-contained: nothing to
147
- locate or `dlopen` at runtime, no separate shared library to ship
148
- alongside it. `src/binding.ts` loads that addon via
149
- [`node-gyp-build`](https://github.com/prebuild/node-gyp-build) (a
150
- zero-dependency runtime loader) and re-exports its typed functions;
151
- `src/identity.ts` (and everything built on top of it) is the actual
152
- public TypeScript API, never touching the addon directly.
153
-
154
- **Memory ownership**, copied from macula-php's `cabi/` rather than
155
- reinvented: every opaque Go value (an identity keypair, a session, or an
156
- inbound "pending call" awaiting a `serve()` handler's reply) crosses the
157
- boundary as a `uintptr_t` from `runtime/cgo.Handle`. Hold it, pass it
158
- back for every operation on that value, and free it exactly once
159
- (`Identity#dispose()` on the TS side; a pending-call handle is freed
160
- automatically by whichever of `macula_pending_call_reply_result`/`_error`
161
- answers it). Fixed-length fields (a 32-byte NodeID or seed) are written
162
- directly into a caller-supplied output buffer. Every exported function
163
- resolves handles through a `recover()`-guarded lookup, never a raw
164
- `cgo.Handle(h)` — `cgo.Handle`'s own `.Value()`/`.Delete()` panic, not
165
- return an error, on a handle this process never issued or already freed.
166
-
167
- **RPC payloads cross this boundary as JSON text**, not another handle —
168
- `cabi/wirevalue.go` converts to/from macula-go's `cbor.Value`, ported from
169
- [macula-cli](https://github.com/macula-io/macula-cli)'s
170
- `internal/wirevalue` package (already proven against the same no-bool
171
- rule) rather than reinvented, plus a reserved `{"$bytes": base64}` object
172
- for bytes (see the RPC caller role below). `Session.serve()`
173
- cannot hand a Go closure across the FFI boundary the way `ServeOneCall`
174
- expects, since the actual answer has to come from arbitrary, possibly-
175
- async TypeScript — so `cabi/serve.go` splits that one blocking Go call
176
- into three cgo exports instead (wait-for-call, read the pending call's
177
- procedure/payload, reply), the same split
178
- [macula-php](https://github.com/macula-io/macula-php)'s `cabi/serve.go`
179
- already proved for the identical problem.
180
-
181
- Development history (every bug found while building this, and how it was
182
- fixed) is in [CHANGELOG.md](CHANGELOG.md).
146
+ `cabi/` exports C functions over macula-go's `pool` (and `stationlink`
147
+ streams). Every Go value crosses as a `runtime/cgo.Handle`; payloads cross as
148
+ JSON with no booleans and bytes as `{"$bytes": "<base64>"}` going in. Every call
149
+ that does network I/O runs on a worker thread (`Napi::AsyncWorker`) and returns
150
+ a Promise; events, served calls and served streams reach JavaScript through a
151
+ `ThreadSafeFunction`.
183
152
 
184
153
  ## What's implemented
185
154
 
186
- - **Identity** — `Identity.generate()` (a fresh, S/Kademlia
187
- puzzle-hardened Ed25519 identity), `Identity.fromSeedBytes()`
188
- (deterministic reconstruction from a saved 32-byte seed),
189
- `identity.nodeId`/`.privateSeedBytes`/`.dispose()`, and
190
- `identity.sign(data)` — a generic Ed25519 primitive (no
191
- application-specific message format baked in; `data` is signed exactly
192
- as given). Using a disposed `Identity` throws instead of signing with a
193
- freed handle.
194
- - **Session** — `Session.connect(host, port, identity)` dials a real
195
- macula-station and completes the CONNECT/HELLO handshake (WebPKI
196
- trust). `session.remoteAddr`, `session.stationNodeId` (the
197
- HELLO-verified station identity), `session.close(identity, reason?)`
198
- (idempotent). Using a session's accessors after `close()` throws
199
- cleanly rather than crashing.
200
- - **RPC, caller role** — `session.call(procedure, payload, opts?)` sends
201
- a signed CALL and waits for the matching RESULT/ERROR.
202
- `payload`/the return value are `JsonValue` (string/number/null/array/
203
- object — **no boolean**, since macula's wire CBOR has no bool type;
204
- encode `true`/`false` as `1`/`0` yourself). **Bytes** go in as an
205
- object whose only key is `$bytes`, holding standard padded base64:
206
- `{"$bytes": "AQID"}` is the bytes `01 02 03`. Any other value under
207
- that sole key is an error, an object with more keys stays a map, and a
208
- plain string is always text. Bytes come back as `"0x"`-prefixed hex by
209
- default; `opts.bytes: "tagged"` returns them in the same `$bytes` form,
210
- so a returned id can be passed straight back. A BOLT#4 ERROR frame (e.g.
211
- `unknown_next_peer`) rejects with a `MaculaCallError` carrying the
212
- numeric `code`, `bolt4Name`, `retryable`, and `detail`. `opts.realm` (a
213
- 64-character hex string) scopes the call to a realm other than the
214
- all-zero default; `callWithUcan()`/`publish()`/`subscribe()` take the
215
- identical option.
216
- - **RPC, provider role** — `session.serve(procedure, handler, opts?)` advertises
217
- `procedure` and answers inbound CALLs against it forever, invoking
218
- `handler(payload)` for each (sync or async; `opts.bytes` as for
219
- `call()`). Resolves with an async
220
- `stop()` that unadvertises and waits for the current poll tick to
221
- finish. Only one `serve()` per `Session` at a time, since a second would
222
- answer CALLs meant for the first, and a `Session` takes one role at a
223
- time: `call()` refuses while a `serve()` or `subscribe()` is active on
224
- it. Open a second `Session` for the other role.
225
- - **DHT** — `session.findRecordsByType(recordType)`,
226
- `session.findRecords(key)`, `session.findRecord(key)`, and
227
- `session.putProcedureAdvertisement(procedure, servingStation, opts?)`/
228
- `session.putContentAnnouncement(mcid, endpoint, ttlMs?)` — typed
229
- builders wrapping macula-go's own `dht.NewProcedureAdvertisement`/
230
- `NewContentAnnouncement` (signed via `dht.Sign`, stored via
231
- `dht.PutRecord`). There is deliberately no generic
232
- `putRecord(type, arbitraryPayload)` — a procedure_advertisement/
233
- content_announcement payload carries raw pubkey/MCID fields that must
234
- be actual CBOR byte strings, which only the typed builders guarantee.
235
- - **Pubsub** — `session.publish(topic, payload, opts?)` (fire-and-forget,
236
- no ack on the wire) and `session.subscribe(topic, handler, opts?)`
237
- (`opts.bytes` as for `call()`).
238
- `subscribe()` resolves with an async `stop()` that sends UNSUBSCRIBE
239
- and does not resolve until the underlying reader goroutine has
240
- genuinely exited. Only one `subscribe()` (and no active `serve()`) per
241
- `Session` at a time — `publish()` itself is exempt, since it only ever
242
- writes, so a `Session` can safely `publish()` on the same topic it's
243
- `subscribe()`d to.
244
- - **Content transfer** — `session.putContent(data, name?)` /
245
- `session.getContent(mcid)`, sent on their own dedicated QUIC stream
246
- (not the control stream, and outside the one-role rule, so they run
247
- alongside an active `serve()`/`subscribe()`). Data above 256 KiB is chunked and reassembled
248
- automatically. `mcid` crosses the boundary as a lowercase hex string.
249
- **This is a one-time TRANSFER mechanism, not durable object storage** —
250
- a station may forget content after serving it, and there is no
251
- list/delete operation.
252
- - **UCAN** — `Ucan.mint(issuer, audience, capabilities?, opts?)` (a
253
- JWT-shaped, EdDSA-signed capability token, UCAN spec `"0.10.0"`) and
254
- `Ucan.decode(token)` (parses claims WITHOUT verifying signature or
255
- expiry — `Ucan#isExpired` mirrors macula-go's own semantics). Both are
256
- pure local operations, no network I/O. `issuer` is written as
257
- `did:macula:<hex NodeID>` and `audience` as the audience NodeID in
258
- lowercase hex. `session.callWithUcan(procedure, payload, ucanToken,
259
- opts?)` attaches a token to an outgoing CALL, for invoking a procedure
260
- gated behind a provider-side `ucan.Policy.Required` policy. A gated
261
- provider accepts a token only from the caller its `aud` names, so mint
262
- it for the identity that will present it; `callWithUcan` attaches
263
- whatever token it is given. This SDK does **not**
264
- expose `ucan.Verify` or `ucan.Policy` — only minting, inspecting, and
265
- attaching a token are implemented; enforcing one is provider-side, out
266
- of scope here.
267
- - **Direct-dial** — `session.resolveDirect(procedure, opts?)`,
268
- `session.callDirect(procedure, payload, opts?)`,
269
- `session.callDirectWithUcan(procedure, payload, ucanToken, opts?)`
270
- (caller side) and `session.advertiseDirect(procedure, opts?)` plus a
271
- standalone `keepAdvertisedDirect(session, procedure, opts?)` helper
272
- (provider side). Resolves a signed `procedure_advertisement` DHT record
273
- to its serving station's own signed `station_endpoint`, then dials that
274
- station directly in one hop instead of depending on advertise-gossip
275
- having reached whichever station the caller happens to already be
276
- connected to. Every advertisement that verifies is a candidate:
277
- `resolveDirect()` asks the DHT again until one's station endpoint
278
- resolves, within `opts.deadlineMs` (10 s when unset), and `callDirect()`
279
- moves on to the next candidate when a dial fails, within its own
280
- `deadlineMs`. Trust is enforced at the application layer: the freshly
281
- connected peer's HELLO-proven identity is checked against the exact
282
- pubkey the signed DHT chain resolved. `advertiseDirect()` issues both a
283
- plain ADVERTISE and the signed DHT record on the same call — both are
284
- required for `resolveDirect()`+`callDirect()` to actually reach a live
285
- route. `resolveDirect`/`callDirect`/`callDirectWithUcan`/
286
- `advertiseDirect` follow the same one-role rule as `call()`/the DHT
287
- methods; a long-lived provider that also serves the
288
- same procedure needs a separate `Session` (and identity — this fleet
289
- enforces one connection per identity) to keep re-advertising on, which
290
- is why `keepAdvertisedDirect()` is a standalone function rather than a
291
- `Session` method.
292
-
293
- - **Pool** — `Pool.connect(seeds, controlIdentity, opts)` holds live
294
- connections to every configured seed concurrently (not
295
- dial-one-then-fallback-on-failure), each independently monitored and
296
- respawned with backoff on disconnect; `publish()`/`call()` fan out
297
- over live links, `subscribe()` re-establishes automatically on
298
- reconnect; `call()` and `subscribe()` take the same `bytes` option. Ports `macula/src/client/macula_client.erl`'s pool design;
299
- see `pool.ts`'s own module doc for why it's a set of role-scoped
300
- `Session`s per seed rather than one, given the single-reader
301
- constraint below.
302
-
303
- Every item above is live-verified against the real production fleet
304
- (`station-de-frankfurt.macula.io`), including negative/error paths and,
305
- where applicable, the actual packaged npm tarball rather than only the
306
- dev build — see [CHANGELOG.md](CHANGELOG.md) for the specific
307
- assertions, bugs found, and fixes for each.
308
-
309
- ## What's explicitly not yet implemented
310
-
311
- Streaming RPC, streaming/content direct-dial (`OpenStreamDirect`,
312
- `PutDirect`/`GetDirect` — plain `Session.call`/`serve` direct-dial is
313
- implemented, see above), cert-chain-authorized direct-dial
314
- (`ResolveWithCertChain`/`CallWithCertChain`/`AdvertiseDirectWithCertChain`
315
- — opt-in even in macula-go itself), provider-side UCAN policy gating
316
- (`ucan.Policy`/`ServeOneCallGated` — this SDK can mint/attach a token but
317
- not enforce one on a served procedure), per-realm `serve`/`advertise`
318
- (these two still only ever use the all-zero realm — `call`/`callWithUcan`/
319
- `publish`/`subscribe`, and DHT's `putProcedureAdvertisement`, all DO now
320
- take an optional realm), a generic "put any DHT record type with an
321
- arbitrary payload" function (see above for why), a `station_endpoint`
322
- record builder (macula-go has none either — stations publish those
323
- themselves, not clients), and `Pinned`/`Insecure` trust modes (`WebPKI`
324
- only so far). Multiple concurrent `subscribe()` topics on one `Session`
325
- still isn't supported at the `Session` level itself — one `subscribe()`
326
- (like one `serve()`) per `Session` at a time; open a second `Session`
327
- for a second topic (`Pool`, above, does exactly this internally to give
328
- each tracked topic its own session). Each of these is a separate, later
329
- slice of work built on top of a working `Session`.
155
+ | Primitive | Caller | Provider | Notes |
156
+ |---|---|---|---|
157
+ | Node keys (`NodeKey`) | ✅ | ✅ | `pq_hybrid` (the fleet's) or `pq_pure`; key files readable by the owner only |
158
+ | Pool of station links (`Pool.connect`) | ✅ | ✅ | Seeds pinned by node_id; realm keys pinned; links redialed with subscriptions and served procedures replayed |
159
+ | Calls by direct dial (`call`, `providers`) | ✅ | ✅ | `serve`: a thrown error goes back as `handler_error`; errors arrive as `ProviderError` / `RelayError` |
160
+ | 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 |
161
+ | Streams (`openStream`, `serveStream`) | ✅ | ✅ | Server, client and bidi; a QUIC stream per session, released on every path |
162
+ | Publish/subscribe | ✅ | ✅ | Signed publications, delivered once across links |
163
+ | DHT (`findRecord`, `findRecords`, `findRecordsByType`, `putRecord`) | ✅ | — | Records verified before they are handed on |
164
+ | Node-served content (`shareContent`, `unshareContent`, `getContent`) | ✅ | ✅ | 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` |
165
+
166
+ ## Not yet implemented
167
+
168
+ - **UCAN-gated calls and serving.** macula 12 uses post-quantum UCANs
169
+ (macula-go#2). Calls carry no token yet, and a gated procedure cannot be
170
+ served.
330
171
 
331
172
  ## Testing
332
173
 
333
174
  ```bash
334
- npx vitest run # default suite, no network
335
- MACULA_TS_LIVE_STATION=<station host> MACULA_TS_LIVE_OTHER_STATION=<another station host> npm run test:live
175
+ npm test # builds build/teststation, then the offline suite
176
+ npm run test:live # one live station, see below
336
177
  ```
337
178
 
338
- `src/session.live.test.ts`, `src/rpc.live.test.ts`, `src/dht.live.test.ts`,
339
- `src/pubsub.live.test.ts`, `src/content.live.test.ts`,
340
- `src/ucan.live.test.ts`, `src/directdial.live.test.ts`, and
341
- `src/pool.live.test.ts` hit the real
342
- production fleet and are **not** part of default `npm test`/CI — opt in
343
- explicitly, gated behind `MACULA_TS_LIVE`. Same convention as macula-go's
344
- `live` build tag, macula-rust's `#[ignore]`, and macula-dotnet's
345
- `[Trait("Category","Live")]`: real-network tests are written and
346
- runnable, just excluded from the default/CI run so a station outage doesn't
347
- make ordinary CI flaky. `.github/workflows/live.yml` runs them when dispatched
348
- by hand (`workflow_dispatch`), never on push or PR. Its two required inputs name
349
- the stations, and it loads the committed linux-x64 prebuild, the same `.node`
350
- file the npm package ships, after checking its sha256 against the commit.
351
-
352
- A live run names its stations: `MACULA_TS_LIVE_STATION` is the host every live
353
- test uses, and `MACULA_TS_LIVE_OTHER_STATION` is the second station the pool's
354
- multi-station test connects to. Neither has a default. With `MACULA_TS_LIVE`
355
- set, a station that isn't set, or that no session can be opened to, fails the
356
- tests with a message naming its variable instead of skipping them. When
357
- `MACULA_TS_LIVE_WAITS` names a file, each wait for a station to register an
358
- ADVERTISE or SUBSCRIBE is also recorded there.
179
+ `npm test` runs `src/pool.test.ts` against `cabi/cmd/teststation`, a helper
180
+ that runs two in-process macula 12 stations (macula-go's `teststation`) sharing
181
+ a DHT, with a test realm that admits the test's provider nodes. It exercises
182
+ keys, calls by direct dial and their errors, providers, server and client
183
+ streams (and that no stream is left unreleased), pubsub and the DHT, through
184
+ the real addon. No network is needed.
185
+
186
+ `src/fleet.live.test.ts` runs against one real station and is not part of
187
+ `npm test`. It needs `MACULA_TS_LIVE_SEED` (host:port), `MACULA_TS_LIVE_STATION_ID`
188
+ (the station's node_id), `MACULA_TS_LIVE_REALM` and `MACULA_TS_LIVE_REALM_KEY`;
189
+ an unset one fails the run naming it. It reads the DHT, calls `mcl-echo/echo`
190
+ by direct dial and hears its own publication.
191
+ `.github/workflows/live.yml` runs it when dispatched by hand, on the committed
192
+ linux-x64 prebuild.
359
193
 
360
194
  ## Packaging: genuinely zero install-time scripts
361
195
 
@@ -403,7 +237,7 @@ npm run build:go # builds cabi/build/libmacula.a -- must run BEFORE
403
237
  npm install # builds the native addon (via the implicit node-gyp
404
238
  # rebuild above) and installs JS deps
405
239
  npm run typecheck
406
- npm test
240
+ npm test # builds build/teststation (Go) first
407
241
  npm run build:prebuilds # regenerate prebuilds/ after touching addon/ or cabi/ -- commit the result
408
242
  npm run build # local dev build: addon + tsc
409
243
  ```
package/dist/binding.d.ts CHANGED
@@ -1,47 +1,52 @@
1
- export type Handle = number | bigint;
1
+ /** An opaque Go value's handle (runtime/cgo.Handle), as the addon returns it. */
2
+ export type Handle = bigint;
3
+ /** What a listener (a subscription, a served procedure, a served stream
4
+ * procedure) is handed, on the event loop: an event or closed notice with its
5
+ * JSON, or a request with the pending call's or stream's handle. */
6
+ export interface Delivery {
7
+ readonly kind: "event" | "closed" | "request";
8
+ readonly json: string;
9
+ readonly handle: Handle;
10
+ }
11
+ export type Listener = (delivery: Delivery) => void;
2
12
  export declare const native: {
3
- identityGenerate(): bigint;
4
- identityFromSeedBytes(seed32: Uint8Array): bigint;
5
- identityNodeId(handle: Handle): Uint8Array;
6
- identityPrivateBytes(handle: Handle): Uint8Array;
7
- identityFree(handle: Handle): void;
8
- identitySign(handle: Handle, data: Uint8Array): Uint8Array;
9
- sessionConnect(host: string, port: number, identityHandle: Handle): Promise<bigint>;
10
- sessionRemoteAddr(handle: Handle): string;
11
- sessionStationNodeId(handle: Handle): Uint8Array;
12
- sessionClose(handle: Handle, identityHandle: Handle, reason: string): Promise<void>;
13
- sessionCall(sessionHandle: Handle, identityHandle: Handle, procedure: string, realm: Uint8Array | undefined, payloadJson: string, timeoutMs: number, bytesMode: number): Promise<string>;
14
- ucanMint(identityHandle: Handle, issuer: string, audience: string, capabilitiesJson: string, expiresAt: number | undefined, notBefore: number | undefined, nonce: string, factsJson: string | undefined, proofsJson: string | undefined): string;
15
- ucanDecode(token: string): string;
16
- sessionCallWithUcan(sessionHandle: Handle, identityHandle: Handle, procedure: string, realm: Uint8Array | undefined, payloadJson: string, timeoutMs: number, ucanToken: string, bytesMode: number): Promise<string>;
17
- sessionAdvertise(sessionHandle: Handle, identityHandle: Handle, realm: Uint8Array | undefined, procedure: string): Promise<void>;
18
- sessionUnadvertise(sessionHandle: Handle, identityHandle: Handle, realm: Uint8Array | undefined, procedure: string): Promise<void>;
19
- serveWaitForCall(sessionHandle: Handle, identityHandle: Handle, realm: Uint8Array | undefined, procedure: string, timeoutMs: number): Promise<Handle | null>;
20
- pendingCallProcedure(pendingHandle: Handle): string;
21
- pendingCallPayloadJson(pendingHandle: Handle, bytesMode: number): string;
22
- pendingCallReplyResult(pendingHandle: Handle, resultJson: string): Promise<void>;
23
- pendingCallReplyError(pendingHandle: Handle, detail: string): Promise<void>;
24
- dhtFindRecordsByType(sessionHandle: Handle, identityHandle: Handle, recordType: number): Promise<string>;
25
- dhtFindRecords(sessionHandle: Handle, identityHandle: Handle, key32: Uint8Array): Promise<string>;
26
- dhtFindRecord(sessionHandle: Handle, identityHandle: Handle, key32: Uint8Array): Promise<string | null>;
27
- dhtPutProcedureAdvertisement(sessionHandle: Handle, identityHandle: Handle, realm: Uint8Array | undefined, procedure: string, servingStation32: Uint8Array, ttlMs: number): Promise<string>;
28
- dhtPutContentAnnouncement(sessionHandle: Handle, identityHandle: Handle, mcid34: Uint8Array, endpoint: string, ttlMs: number): Promise<string>;
29
- sessionPublish(sessionHandle: Handle, identityHandle: Handle, realm: Uint8Array | undefined, topic: string, payloadJson: string, ttlMs: number): Promise<void>;
30
- sessionSubscribeStart(sessionHandle: Handle, identityHandle: Handle, realm: Uint8Array | undefined, topic: string, onEvent: (msg: {
31
- kind: "event";
32
- topic: string;
33
- publisher: Uint8Array;
34
- seq: number;
35
- payloadJson: string;
36
- } | {
37
- kind: "closed";
38
- error: string;
39
- }) => void, bytesMode: number): Promise<bigint>;
40
- sessionSubscribeStop(subscriptionHandle: Handle): Promise<void>;
41
- contentPut(sessionHandle: Handle, identityHandle: Handle, data: Uint8Array, name: string): Promise<string>;
42
- contentGet(sessionHandle: Handle, identityHandle: Handle, mcidHex: string): Promise<Uint8Array | null>;
43
- directdialResolve(sessionHandle: Handle, identityHandle: Handle, realm: Uint8Array | undefined, procedure: string, timeoutMs: number): Promise<string>;
44
- directdialCall(sessionHandle: Handle, identityHandle: Handle, procedure: string, realm: Uint8Array | undefined, payloadJson: string, timeoutMs: number, bytesMode: number): Promise<string>;
45
- directdialCallWithUcan(sessionHandle: Handle, identityHandle: Handle, procedure: string, realm: Uint8Array | undefined, payloadJson: string, timeoutMs: number, ucanToken: string, bytesMode: number): Promise<string>;
46
- directdialAdvertise(sessionHandle: Handle, identityHandle: Handle, realm: Uint8Array | undefined, procedure: string, ttlMs: number): Promise<void>;
13
+ keyGenerate(profile: string): Promise<Handle>;
14
+ keyLoad(path: string, profile: string): Promise<Handle>;
15
+ keySave(key: Handle, path: string): Promise<void>;
16
+ keyNodeId(key: Handle): Uint8Array;
17
+ keyPublicKey(key: Handle): Uint8Array;
18
+ keyProfile(key: Handle): string;
19
+ keySign(key: Handle, data: Uint8Array): Promise<Uint8Array>;
20
+ keyFree(key: Handle): void;
21
+ poolConnect(key: Handle, seedsJson: string, optionsJson: string): Promise<Handle>;
22
+ poolClose(pool: Handle): Promise<void>;
23
+ poolNodeId(pool: Handle): Uint8Array;
24
+ poolStatus(pool: Handle): string;
25
+ poolCall(pool: Handle, realm: Uint8Array, procedure: string, payloadJson: string, provider: Uint8Array | null, timeoutMs: number, bytesMode: number): Promise<string>;
26
+ poolProviders(pool: Handle, realm: Uint8Array, procedure: string, timeoutMs: number): Promise<string>;
27
+ poolPublish(pool: Handle, realm: Uint8Array, topic: string, payloadJson: string, ttlMs: number): Promise<void>;
28
+ poolSubscribe(pool: Handle, realm: Uint8Array, topic: string, bytesMode: number, listener: Listener): Promise<Handle>;
29
+ subscriptionStop(subscription: Handle): Promise<void>;
30
+ poolFindRecord(pool: Handle, key: Uint8Array, timeoutMs: number, bytesMode: number): Promise<string>;
31
+ poolFindRecords(pool: Handle, key: Uint8Array, timeoutMs: number, bytesMode: number): Promise<string>;
32
+ poolFindRecordsByType(pool: Handle, type: number, timeoutMs: number, bytesMode: number): Promise<string>;
33
+ poolPutRecord(pool: Handle, wire: Uint8Array, timeoutMs: number): Promise<void>;
34
+ poolShareContent(pool: Handle, realm: Uint8Array, data: Uint8Array, name: string, timeoutMs: number): Promise<Uint8Array>;
35
+ poolUnshareContent(pool: Handle, realm: Uint8Array, mcid: Uint8Array, timeoutMs: number): Promise<void>;
36
+ poolGetContent(pool: Handle, realm: Uint8Array, mcid: Uint8Array, maxBytes: number, maxChunks: number, parallel: number, chunkTimeoutMs: number, timeoutMs: number): Promise<Uint8Array>;
37
+ poolServe(pool: Handle, realm: Uint8Array, procedure: string, bytesMode: number, listener: Listener): Promise<Handle>;
38
+ poolServeStream(pool: Handle, realm: Uint8Array, procedure: string, mode: number, bytesMode: number, listener: Listener): Promise<Handle>;
39
+ pendingReply(pending: Handle, resultJson: string): void;
40
+ pendingError(pending: Handle, message: string): void;
41
+ servedStop(served: Handle): Promise<void>;
42
+ poolOpenStream(pool: Handle, realm: Uint8Array, procedure: string, mode: number, payloadJson: string, provider: Uint8Array | null, deadlineMs: number, timeoutMs: number): Promise<Handle>;
43
+ streamSendBytes(stream: Handle, data: Uint8Array): Promise<void>;
44
+ streamSendJson(stream: Handle, valueJson: string): Promise<void>;
45
+ streamCloseSend(stream: Handle): Promise<void>;
46
+ streamClose(stream: Handle): Promise<void>;
47
+ streamReply(stream: Handle, payloadJson: string): Promise<void>;
48
+ streamAbort(stream: Handle, code: string, message: string): Promise<void>;
49
+ streamRecv(stream: Handle, timeoutMs: number, bytesMode: number): Promise<string>;
50
+ streamRequest(stream: Handle, bytesMode: number): string;
51
+ streamFree(stream: Handle): Promise<void>;
47
52
  };