@macula-io/ts 0.13.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.
@@ -0,0 +1,807 @@
1
+ // A handshaked connection to a real macula-station: transport +
2
+ // CONNECT/HELLO, reached through cabi's FFI boundary exactly like
3
+ // Identity generation was -- connection.Connect on the Go side already
4
+ // does the real QUIC dial, the CBOR frame encoding, and the Ed25519
5
+ // sign/verify of the handshake; this file does not reimplement any of
6
+ // that, it exposes it.
7
+ //
8
+ // Scope of this slice: connect/close plus unary RPC, both roles (call
9
+ // as caller, serve as provider -- see rpc.ts for the shared payload/
10
+ // error shapes). No pubsub, no DHT, no content transfer, no streaming,
11
+ // no UCAN -- those build on top of a working Session and are separate
12
+ // work.
13
+ import { native } from "./binding.js";
14
+ import { ContentNotFoundError } from "./content.js";
15
+ import { DHT_DEFAULT_TTL_MS } from "./dht.js";
16
+ import { DEFAULT_CALL_TIMEOUT_MS, MaculaCallError, SERVE_POLL_MS } from "./rpc.js";
17
+ const REALM_HEX_PATTERN = /^[0-9a-fA-F]{64}$/;
18
+ /** Decodes CallOptions.realm/PublishOptions.realm/SubscribeOptions.realm's
19
+ * public hex-string convention into the 32-byte Uint8Array native.*
20
+ * already accepts for its own `realm` parameter on sessionCall/
21
+ * sessionCallWithUcan/sessionPublish/sessionSubscribeStart -- cabi/rpc.go,
22
+ * cabi/pubsub.go, and addon/binding.cc were, on inspection, already fully
23
+ * wired for an optional realm all the way through (ReadOptionalRealm in
24
+ * binding.cc, realm32OrZero in cabi/main.go); this class's own methods
25
+ * were the only place still hardcoding `undefined`. Kept as a hex string
26
+ * at the PUBLIC surface specifically to match DhtRecord's existing
27
+ * convention, while reusing that already-working raw-byte plumbing
28
+ * beneath it unchanged, rather than re-threading the FFI boundary itself
29
+ * as a second, redundant string convention alongside it.
30
+ * `undefined` in, `undefined` out -- the all-zero-realm default. */
31
+ function realmBytesFromHex(realm) {
32
+ if (realm === undefined)
33
+ return undefined;
34
+ if (!REALM_HEX_PATTERN.test(realm)) {
35
+ throw new Error(`macula-ts: realm must be exactly 64 hex characters (32 bytes), got ${JSON.stringify(realm)}`);
36
+ }
37
+ return new Uint8Array(Buffer.from(realm, "hex"));
38
+ }
39
+ export class Session {
40
+ #handle;
41
+ // Retained since connect() purely so call()/serve() (added this
42
+ // slice) don't force every caller to re-pass the identity they just
43
+ // used to open the session -- connection.Session itself has no such
44
+ // field (every Go-side signing call takes identity.KeyPair
45
+ // explicitly, see connection.go), so this is a convenience this
46
+ // wrapper adds, not something mirrored from macula-go. close()
47
+ // deliberately still takes identity as an explicit parameter (see
48
+ // its own doc below) -- that contract predates this field and is
49
+ // unchanged, per this repo's own "extend, don't replace" rule.
50
+ #identity;
51
+ #activeServe = null;
52
+ #activeSubscription = null;
53
+ // Serializes every operation that reads this Session's shared
54
+ // control stream: call()/callWithUcan()/the DHT methods, plus
55
+ // serve()'s advertise + each poll tick + unadvertise, plus
56
+ // subscribe()'s start + stop. macula-go's connection/frame_stream.go
57
+ // RecvFrame mutates a shared buffer with no mutex of its own, so two
58
+ // reads racing it corrupt the stream -- verified live: `Promise.all`
59
+ // of 4 concurrent call()s on one Session left EVERY later read on
60
+ // that same Session permanently failing "claimed frame length ...
61
+ // exceeds the 16777215-byte cap" (a torn buffer), not just that one
62
+ // batch. The `#activeServe`/`#activeSubscription` checks above only
63
+ // ever protected call()-family operations from an ACTIVE serve()/
64
+ // subscribe() -- they did nothing to stop two ordinary call()s (or a
65
+ // call() racing a DHT method) from racing each other, since neither
66
+ // flag is set in that case. This queue is what actually closes that
67
+ // gap: every control-stream-reading native call funnels through
68
+ // #enqueue, so only one is ever in flight at a time, regardless of
69
+ // which method it came from. publish()/putContent()/getContent() do
70
+ // NOT go through this -- see their own docs for why (a pure write,
71
+ // and each own dedicated QUIC stream, respectively -- neither reads
72
+ // this shared stream at all).
73
+ #queue = Promise.resolve();
74
+ #enqueue(fn) {
75
+ const result = this.#queue.then(fn, fn);
76
+ // The chain itself must never become a rejected promise -- that
77
+ // would wedge every future caller behind a permanently-broken
78
+ // link. Each waiter still gets ITS OWN real result/rejection via
79
+ // the `result` returned below; only the internal sequencing link
80
+ // swallows the outcome.
81
+ this.#queue = result.then(() => undefined, () => undefined);
82
+ return result;
83
+ }
84
+ constructor(handle, identity) {
85
+ this.#handle = handle;
86
+ this.#identity = identity;
87
+ }
88
+ /** Dials host:port and completes the full CONNECT/HELLO handshake
89
+ * against a real macula-station, via macula-go's connection.Connect
90
+ * (WebPKI trust -- standard CA-bundle validation, matching what the
91
+ * production fleet actually presents; Pinned/Insecure trust modes
92
+ * aren't exposed yet).
93
+ *
94
+ * This is real network I/O -- a QUIC dial plus a signed round trip,
95
+ * bounded by macula-go's own ~30s handshake timeout -- so it's async
96
+ * on both sides of the FFI boundary (see addon/binding.cc's
97
+ * ConnectWorker): awaiting this never blocks Node's event loop.
98
+ *
99
+ * `identity` must stay non-disposed for the life of the returned
100
+ * Session -- close() needs it again to sign GOODBYE. */
101
+ static async connect(host, port, identity) {
102
+ const handle = await native.sessionConnect(host, port, identity.handleForFfi());
103
+ return new Session(handle, identity);
104
+ }
105
+ #requireHandle() {
106
+ if (this.#handle === null) {
107
+ throw new Error("macula-ts: Session used after close()");
108
+ }
109
+ return this.#handle;
110
+ }
111
+ /** call()'s own handle-plus-exclusivity guard, factored out since the
112
+ * DHT methods below (findRecord/findRecords/findRecordsByType/
113
+ * putRecord) all end up on this Session's same shared control stream
114
+ * too -- macula-go's dht.FindRecord et al. are themselves just a
115
+ * connection.Session.Call under the hood (see cabi/dht.go), so mixing
116
+ * one of these with an active serve() OR subscribe() on this Session
117
+ * races exactly the way call() itself would: serve()'s poll loop and
118
+ * subscribe()'s background reader both read frames off this same
119
+ * stream on their own schedule, same as call()'s own blocking read
120
+ * does. publish() is deliberately NOT guarded by this: it only ever
121
+ * WRITES a fire-and-forget frame (connection.Session.Publish), never
122
+ * reads, so it does not race a concurrent reader the way call()/
123
+ * serve()/subscribe()/the DHT methods do -- and the live pubsub round
124
+ * trip this SDK's own test performs (subscribe(), then publish() on
125
+ * the SAME Session while that subscription is active) depends on
126
+ * publish() staying unguarded here. */
127
+ #requireHandleNotServing(caller) {
128
+ const handle = this.#requireHandle();
129
+ if (this.#activeServe !== null) {
130
+ throw new Error(`macula-ts: Session.${caller}() while serve("${this.#activeServe.procedure}") is active on the same ` +
131
+ `Session races on the shared control stream -- open a second Session for the other role.`);
132
+ }
133
+ if (this.#activeSubscription !== null) {
134
+ throw new Error(`macula-ts: Session.${caller}() while subscribe("${this.#activeSubscription.topic}") is active on the ` +
135
+ `same Session races on the shared control stream -- open a second Session for the other role.`);
136
+ }
137
+ return handle;
138
+ }
139
+ /** The address this session's underlying QUIC connection is with. */
140
+ get remoteAddr() {
141
+ return native.sessionRemoteAddr(this.#requireHandle());
142
+ }
143
+ /** The station's HELLO-verified 32-byte NodeID (Ed25519 public key)
144
+ * -- proof, beyond "connect() didn't throw", that this is a real,
145
+ * application-layer-verified session and not just a QUIC/TLS
146
+ * handshake: frame.Verify already checked this NodeID's signature
147
+ * over the HELLO frame inside connect(), this just surfaces it. */
148
+ get stationNodeId() {
149
+ return native.sessionStationNodeId(this.#requireHandle());
150
+ }
151
+ /** Sends a signed GOODBYE and closes the connection. Safe to call
152
+ * more than once -- a second call is a no-op, matching Identity's
153
+ * dispose() convention. `identity` must be the same (non-disposed)
154
+ * identity used to open this session; Close needs it to sign
155
+ * GOODBYE. Like connect(), this is real network I/O (a drain sleep
156
+ * plus a write) and runs off the main thread on the native side.
157
+ *
158
+ * Stops an active subscribe() FIRST, if there is one, sending its
159
+ * UNSUBSCRIBE over the still-open connection before that connection
160
+ * goes away -- unlike every other resource this SDK hands out (an
161
+ * Identity, an unstopped serve() loop), an unstopped subscription
162
+ * left dangling here does not just leak memory: its background reader
163
+ * goroutine holds a live Napi::ThreadSafeFunction, which deliberately
164
+ * keeps Node's event loop alive on its own (see subscribe()'s own doc
165
+ * -- a program that does nothing but subscribe() and wait needs
166
+ * exactly this to stay alive for events to arrive at all). Verified
167
+ * live: closing a Session out from under an active subscription
168
+ * without this hung the process forever, not merely leaked a handle
169
+ * -- close() closing it first is what makes "forgot to call the
170
+ * returned stop()" fail safe instead of fail hung. */
171
+ async close(identity, reason = "") {
172
+ if (this.#handle === null)
173
+ return;
174
+ if (this.#activeSubscription !== null) {
175
+ // Swallowed, not awaited-and-propagated: a subscription whose
176
+ // connection already died (or whose stop() otherwise fails) must
177
+ // never prevent this Session from actually closing -- verified
178
+ // live that letting that rejection abort close() left the
179
+ // Session's handle un-nulled (leaked) and unclosable on every
180
+ // subsequent attempt. Whatever happened here is either already
181
+ // reported (subscribe()'s own onClosed, if that's why this
182
+ // failed) or genuinely not this method's problem to surface --
183
+ // close() closing the underlying session either way is the
184
+ // actual guarantee this method makes.
185
+ try {
186
+ await this.#activeSubscription.stop();
187
+ }
188
+ catch (err) {
189
+ console.error(`macula-ts: session.close() couldn't cleanly stop an active subscription first (closing anyway):`, err);
190
+ }
191
+ }
192
+ const handle = this.#handle;
193
+ this.#handle = null;
194
+ await native.sessionClose(handle, identity.handleForFfi(), reason);
195
+ }
196
+ /** Caller role: sends a signed CALL for `procedure` and waits for the
197
+ * matching RESULT or ERROR (macula-go's connection.Session.Call).
198
+ * `payload` is JSON, converted to a cbor.Value on the Go side
199
+ * (cabi/wirevalue.go) -- see rpc.ts's JsonValue for the wire's own
200
+ * restrictions (no booleans, bytes as hex strings).
201
+ *
202
+ * Resolves with the RESULT's payload on success. Rejects with a
203
+ * MaculaCallError (rpc.ts) when a real BOLT#4 ERROR frame came back
204
+ * instead -- e.g. `unknown_next_peer` for a procedure nobody has
205
+ * advertised -- carrying its code/name/retryable triple rather than
206
+ * a generic message; rejects with a plain Error for everything that
207
+ * isn't a wire-level answer at all (a local timeout, a dead session,
208
+ * a payload the wire can't represent).
209
+ *
210
+ * Real network I/O -- a signed frame out and a wait for the reply,
211
+ * up to opts.deadlineMs -- so this runs off the main thread on the
212
+ * native side, like connect()/close(). Do not call this
213
+ * concurrently with an active serve() on the SAME Session: both read
214
+ * frames off the one shared control stream, and macula-go's own
215
+ * ServeOneCall/Call docs both warn that mixing roles on one
216
+ * connection races (an unrelated frame arriving first is discarded,
217
+ * not queued) -- open a second Session for the other role instead,
218
+ * exactly what this SDK's own live test does. */
219
+ async call(procedure, payload, opts = {}) {
220
+ const handle = this.#requireHandleNotServing("call");
221
+ const timeoutMs = opts.deadlineMs ?? DEFAULT_CALL_TIMEOUT_MS;
222
+ const payloadJson = JSON.stringify(payload ?? null);
223
+ const realm = realmBytesFromHex(opts.realm);
224
+ const envelopeJson = await this.#enqueue(() => native.sessionCall(handle, this.#identity.handleForFfi(), procedure, realm, payloadJson, timeoutMs));
225
+ const envelope = JSON.parse(envelopeJson);
226
+ if (envelope.ok)
227
+ return envelope.payload;
228
+ throw new MaculaCallError(envelope.bolt4);
229
+ }
230
+ /** Caller role: call(), attaching `ucanToken` to the outgoing CALL
231
+ * (macula-go's `connection.Session.CallWithUCAN`) -- for invoking a
232
+ * procedure a provider has gated behind a `ucan.Policy.Required`
233
+ * policy on its own side (this SDK does not implement that provider
234
+ * side itself -- see ucan.ts's own module doc). `ucanToken` may be a
235
+ * `Ucan` (as returned by `Ucan.mint()`/`Ucan.decode()` -- its `.token`
236
+ * is attached) or a raw token string directly.
237
+ *
238
+ * Same resolve/reject shape as `call()` in every other respect: the
239
+ * RESULT's payload on success, a `MaculaCallError` for a real BOLT#4
240
+ * ERROR frame (e.g. `unauthorized` for a token that fails the
241
+ * provider's policy check, or `unknown_next_peer` for a procedure
242
+ * nobody has advertised), a plain `Error` for anything that never got
243
+ * a wire-level answer at all.
244
+ *
245
+ * Deliberately attaches whatever token bytes it is given with NO local
246
+ * check relating this Session's own identity to `ucanToken`'s `aud`
247
+ * claim -- see ucan.ts's own module doc for why: macula's UCAN gate is
248
+ * a bearer-token check (signature + expiry against the token's
249
+ * issuer), and the real wire-level gate never looks at the caller's
250
+ * identity against `aud` either. A client-side guard here would both
251
+ * reject configurations the mesh accepts fine and misrepresent a
252
+ * security property that isn't actually enforced.
253
+ *
254
+ * Real network I/O, off the main thread on the native side, and
255
+ * subject to the identical same-Session exclusivity rule as `call()`
256
+ * (`#requireHandleNotServing`) -- both end up on the same shared
257
+ * control stream. */
258
+ async callWithUcan(procedure, payload, ucanToken, opts = {}) {
259
+ const handle = this.#requireHandleNotServing("callWithUcan");
260
+ const timeoutMs = opts.deadlineMs ?? DEFAULT_CALL_TIMEOUT_MS;
261
+ const payloadJson = JSON.stringify(payload ?? null);
262
+ const realm = realmBytesFromHex(opts.realm);
263
+ const token = typeof ucanToken === "string" ? ucanToken : ucanToken.token;
264
+ const envelopeJson = await this.#enqueue(() => native.sessionCallWithUcan(handle, this.#identity.handleForFfi(), procedure, realm, payloadJson, timeoutMs, token));
265
+ const envelope = JSON.parse(envelopeJson);
266
+ if (envelope.ok)
267
+ return envelope.payload;
268
+ throw new MaculaCallError(envelope.bolt4);
269
+ }
270
+ /** Provider role: advertises `procedure` (macula-go's
271
+ * connection.Session.Advertise) and answers inbound CALLs against it
272
+ * forever, invoking `handler` for each one (payload in, reply or
273
+ * thrown error out -- `handler` may be async), until the returned
274
+ * stop function is called. A thrown/rejected `handler` becomes a
275
+ * BOLT#4 UnknownError reply carrying the thrown value's message as
276
+ * detail (matching macula-go's connection/serve.go, which maps every
277
+ * handler error to that one code); a `handler` that panics on the Go
278
+ * side instead (not reachable from here -- there is no Go code
279
+ * between this and the JS handler) would map to
280
+ * TemporaryRelayFailure, per that same file.
281
+ *
282
+ * Only one serve() registration is allowed per Session at a time --
283
+ * see call()'s own doc on why mixing roles (or two server loops) on
284
+ * one connection is unsafe, not just inadvisable; open a second
285
+ * Session for a second procedure instead of trying to serve two
286
+ * procedures off one.
287
+ *
288
+ * The returned stop function is async: it unadvertises the
289
+ * procedure (a real network write) and waits for the current poll
290
+ * tick to finish (up to rpc.ts's SERVE_POLL_MS) before resolving --
291
+ * there is no way to interrupt a Go-side wait already in flight, the
292
+ * same bounded-latency shape macula-go's own ServeForever has
293
+ * internally. */
294
+ async serve(procedure, handler) {
295
+ if (this.#activeServe !== null) {
296
+ throw new Error(`macula-ts: Session is already serving "${this.#activeServe.procedure}" -- macula-go's ServeOneCall reads ` +
297
+ `one frame at a time off the shared control stream, so a second concurrent serve() (or a serve() ` +
298
+ `alongside call()) on the same Session races; open a second Session instead.`);
299
+ }
300
+ if (this.#activeSubscription !== null) {
301
+ throw new Error(`macula-ts: Session.serve() while subscribe("${this.#activeSubscription.topic}") is active on the same ` +
302
+ `Session races on the shared control stream -- open a second Session for the other role.`);
303
+ }
304
+ const handle = this.#requireHandle();
305
+ const identityHandle = this.#identity.handleForFfi();
306
+ // Marked BEFORE the advertise await below, not after -- closing the
307
+ // exact race window this repo's own live testing found: a
308
+ // concurrent call()/serve()/subscribe() checks #activeServe
309
+ // synchronously, so it must already be non-null by the time this
310
+ // function's first await yields control, not only once advertise
311
+ // has finished. The placeholder stop() is only reachable if
312
+ // something races this same synchronous tick (impossible in
313
+ // practice, single-threaded JS) or misuses the handle before
314
+ // startup finishes; rolled back to null below if advertise itself
315
+ // fails, so a failed serve() attempt doesn't leave the Session
316
+ // permanently (and incorrectly) marked as serving.
317
+ const placeholderStop = async () => {
318
+ throw new Error(`macula-ts: session.serve("${procedure}") has not finished starting yet`);
319
+ };
320
+ this.#activeServe = { procedure, stop: placeholderStop };
321
+ try {
322
+ await this.#enqueue(() => native.sessionAdvertise(handle, identityHandle, undefined, procedure));
323
+ }
324
+ catch (err) {
325
+ this.#activeServe = null;
326
+ throw err;
327
+ }
328
+ let stopped = false;
329
+ const loopDone = (async () => {
330
+ while (!stopped) {
331
+ let pendingHandle;
332
+ try {
333
+ pendingHandle = await this.#enqueue(() => native.serveWaitForCall(handle, identityHandle, undefined, procedure, SERVE_POLL_MS));
334
+ }
335
+ catch (err) {
336
+ if (!stopped) {
337
+ console.error(`macula-ts: session.serve("${procedure}") poll failed, stopping this server loop:`, err);
338
+ }
339
+ return;
340
+ }
341
+ if (pendingHandle === null)
342
+ continue; // nothing arrived this tick -- poll again
343
+ const payload = JSON.parse(native.pendingCallPayloadJson(pendingHandle));
344
+ try {
345
+ const reply = await handler(payload);
346
+ await native.pendingCallReplyResult(pendingHandle, JSON.stringify(reply ?? null));
347
+ }
348
+ catch (err) {
349
+ const detail = err instanceof Error ? err.message : String(err);
350
+ try {
351
+ await native.pendingCallReplyError(pendingHandle, detail);
352
+ }
353
+ catch (replyErr) {
354
+ console.error(`macula-ts: session.serve("${procedure}") failed to send a reply:`, replyErr);
355
+ }
356
+ }
357
+ }
358
+ })();
359
+ const stop = async () => {
360
+ stopped = true;
361
+ await loopDone;
362
+ this.#activeServe = null;
363
+ if (this.#handle !== null) {
364
+ await this.#enqueue(() => native.sessionUnadvertise(handle, identityHandle, undefined, procedure));
365
+ }
366
+ };
367
+ this.#activeServe = { procedure, stop };
368
+ return stop;
369
+ }
370
+ /** DHT: returns every record of `recordType` currently visible from
371
+ * the station this Session is connected to (macula-go's
372
+ * dht.FindRecordsByType, via a signed CALL to `_dht.find_records_by_type`
373
+ * under the DHT's own reserved realm -- threaded internally, this
374
+ * method never touches a realm itself). Coverage depends on that
375
+ * station's own view of the mesh, not a global guarantee. Neither
376
+ * this nor findRecord/findRecords verifies a returned record's
377
+ * signature or checks its expiry -- see DhtRecord's own doc (dht.ts).
378
+ *
379
+ * Real network I/O, off the main thread on the native side, exactly
380
+ * like call() -- and subject to the same same-Session exclusivity
381
+ * rule as call() (see #requireHandleNotServing's own doc): do not
382
+ * call this while serve() is active on the same Session. */
383
+ async findRecordsByType(recordType) {
384
+ const handle = this.#requireHandleNotServing("findRecordsByType");
385
+ const json = await this.#enqueue(() => native.dhtFindRecordsByType(handle, this.#identity.handleForFfi(), recordType));
386
+ return JSON.parse(json);
387
+ }
388
+ /** DHT: returns every record stored at `key` -- the full
389
+ * signer-deduped multiset at that storage key (macula-go's
390
+ * dht.FindRecords), e.g. every procedure_advertisement for one
391
+ * procedure, not just the first one found. `key` must be exactly 32
392
+ * bytes -- see dht/record.go's ProcedureKey/StationEndpointKey/
393
+ * ContentKey (macula-go) for how those are derived from the thing
394
+ * being looked up. Same I/O and exclusivity notes as
395
+ * findRecordsByType(). */
396
+ async findRecords(key) {
397
+ const handle = this.#requireHandleNotServing("findRecords");
398
+ requireKey32(key);
399
+ const json = await this.#enqueue(() => native.dhtFindRecords(handle, this.#identity.handleForFfi(), key));
400
+ return JSON.parse(json);
401
+ }
402
+ /** DHT: returns ONE record by storage key (macula-go's
403
+ * dht.FindRecord). Resolves `null` when none exists -- mirrors
404
+ * macula-go's own dht.ErrNotFound, translated to a value instead of a
405
+ * thrown error since "not found" is an expected, routine outcome
406
+ * here, not exceptional. Same I/O and exclusivity notes as
407
+ * findRecordsByType(). */
408
+ async findRecord(key) {
409
+ const handle = this.#requireHandleNotServing("findRecord");
410
+ requireKey32(key);
411
+ const json = await this.#enqueue(() => native.dhtFindRecord(handle, this.#identity.handleForFfi(), key));
412
+ return json === null ? null : JSON.parse(json);
413
+ }
414
+ /** DHT: builds the realm-qualified discovery URI (macula-go's
415
+ * dht.DiscoveryURI), then builds (dht.NewProcedureAdvertisement),
416
+ * signs, and stores a procedure_advertisement naming this Session's
417
+ * own Identity as `procedure`'s advertiser and `servingStation` (32
418
+ * bytes -- a station's NodeID, typically this Session's own
419
+ * `stationNodeId`) as the station that serves it.
420
+ *
421
+ * `realm` should be the SAME realm `procedure` is (or will be) served
422
+ * under via `serve()` -- defaults to the all-zero realm, matching
423
+ * `call()`'s own default (see CallOptions.realm's own doc: `call()`/
424
+ * `callWithUcan()`/`publish()`/`subscribe()` now all take an optional
425
+ * realm; `serve()`/`advertise` remain all-zero-realm-only for this
426
+ * slice, unchanged). This method
427
+ * builds the qualified URI itself (dht.DiscoveryURI) rather than
428
+ * taking a pre-qualified string, since NewProcedureAdvertisement's own
429
+ * doc is explicit that "the advertiser and the resolver must derive
430
+ * the identical URI or the DHT storage key will not agree" -- a
431
+ * caller-supplied pre-qualified string invites exactly that class of
432
+ * bug (verified directly: an earlier draft of this SDK's own live
433
+ * test got this wrong by hand-qualifying the URI itself, and its
434
+ * following findRecord() came back not-found until this method built
435
+ * the URI internally instead).
436
+ *
437
+ * Wraps macula-go's REAL constructor rather than a generic
438
+ * JSON-payload builder deliberately -- see cabi/dht.go's own doc on
439
+ * why: two of this record type's payload fields (advertiser_node,
440
+ * serving_station) are raw 32-byte pubkeys that must be actual CBOR
441
+ * byte strings for a real resolver to read, and this SDK's generic
442
+ * JSON<->cbor.Value conversion (rpc.ts's JsonValue, wirevalue.go) has
443
+ * no way to produce those going IN -- only OUT, as "0x"-prefixed hex
444
+ * (see DhtRecord's own doc). `ttlMs` defaults to DHT_DEFAULT_TTL_MS
445
+ * (48h). Resolves with the signed record actually stored. Same I/O
446
+ * and exclusivity notes as findRecordsByType(). */
447
+ async putProcedureAdvertisement(procedure, servingStation, opts = {}) {
448
+ const handle = this.#requireHandleNotServing("putProcedureAdvertisement");
449
+ requireKey32(servingStation);
450
+ if (opts.realm !== undefined)
451
+ requireKey32(opts.realm);
452
+ const json = await this.#enqueue(() => native.dhtPutProcedureAdvertisement(handle, this.#identity.handleForFfi(), opts.realm, procedure, servingStation, opts.ttlMs ?? DHT_DEFAULT_TTL_MS));
453
+ return JSON.parse(json);
454
+ }
455
+ /** DHT: builds (macula-go's dht.NewContentAnnouncement), signs, and
456
+ * stores a content_announcement naming this Session's own Identity as
457
+ * `mcid`'s (34 bytes) announcer, reachable at `endpoint` (a dialable
458
+ * seed URL, e.g. "https://host:4433" -- NOT a station_endpoint's
459
+ * split host/port). Same reasoning as putProcedureAdvertisement()'s
460
+ * own doc for wrapping macula-go's real constructor instead of a
461
+ * generic JSON-payload builder (announcer_node/mcid are the same kind
462
+ * of raw-byte field). `ttlMs` defaults to DHT_DEFAULT_TTL_MS (48h).
463
+ * Same I/O and exclusivity notes as findRecordsByType(). */
464
+ async putContentAnnouncement(mcid, endpoint, ttlMs = DHT_DEFAULT_TTL_MS) {
465
+ const handle = this.#requireHandleNotServing("putContentAnnouncement");
466
+ if (mcid.length !== 34) {
467
+ throw new Error(`macula-ts: content_announcement mcid must be exactly 34 bytes, got ${mcid.length}`);
468
+ }
469
+ const json = await this.#enqueue(() => native.dhtPutContentAnnouncement(handle, this.#identity.handleForFfi(), mcid, endpoint, ttlMs));
470
+ return JSON.parse(json);
471
+ }
472
+ /** Direct-dial (caller side): finds `procedure`'s currently-advertised
473
+ * serving station and its dialable host/port (macula-go's
474
+ * `directdial.Resolve`), via this Session used only to query the DHT
475
+ * -- it does not need to be connected to the station that ends up
476
+ * serving `procedure`. Retries past DHT propagation lag internally (up
477
+ * to ~5s, macula-go's own fixed schedule, not configurable here); a
478
+ * `procedure` nobody ever called `advertiseDirect()` for rejects
479
+ * cleanly after that window (a plain `Error` wrapping macula-go's
480
+ * `ErrProcedureNotAdvertised`), never a hang.
481
+ *
482
+ * `opts.realm` must match whatever realm `procedure` was
483
+ * `advertiseDirect()`d under, or the discovery URI the two sides
484
+ * derive disagrees and this rejects the same way as if nothing was
485
+ * ever advertised at all -- see `AdvertiseDirectOptions.realm`'s own
486
+ * doc (directdial.ts). `callDirect()`/`callDirectWithUcan()` call this
487
+ * internally; it's exposed on its own for callers that just want the
488
+ * resolved station/host/port without also dialing and calling it (e.g.
489
+ * diagnostics).
490
+ *
491
+ * Real network I/O, off the main thread on the native side, subject to
492
+ * the same same-Session exclusivity rule as `call()`/the DHT methods
493
+ * (`#requireHandleNotServing`). */
494
+ async resolveDirect(procedure, opts = {}) {
495
+ const handle = this.#requireHandleNotServing("resolveDirect");
496
+ const realm = realmBytesFromHex(opts.realm);
497
+ const json = await this.#enqueue(() => native.directdialResolve(handle, this.#identity.handleForFfi(), realm, procedure));
498
+ return JSON.parse(json);
499
+ }
500
+ /** Direct-dial (caller side): resolves `procedure`'s provider (via
501
+ * `resolveDirect()`, through this Session) and calls it there, in one
502
+ * hop, in a SEPARATE connection macula-go opens, application-layer-pins
503
+ * against the resolved station identity, and closes again, entirely
504
+ * internally (macula-go's `directdial.Call`) -- this Session's own
505
+ * connection is never touched beyond the DHT lookup. The provider must
506
+ * have `advertiseDirect()`d `procedure` (or the Erlang/Rust/Go
507
+ * equivalent) -- a plain `serve()`-side `Advertise` alone publishes no
508
+ * discoverable DHT record, and this rejects with `ErrProcedureNotAdvertised`.
509
+ *
510
+ * Same resolve/reject shape as `call()`: the RESULT's payload on
511
+ * success, a `MaculaCallError` (rpc.ts) for a real BOLT#4 ERROR frame
512
+ * from the resolved provider, a plain `Error` for a resolve failure, a
513
+ * dial failure, an identity-trust violation (the dialed peer proved a
514
+ * DIFFERENT identity than the DHT chain resolved), or anything else
515
+ * that never got a wire-level answer at all.
516
+ *
517
+ * Real network I/O (DHT lookups, a fresh QUIC dial, then the CALL
518
+ * itself) -- off the main thread on the native side, subject to the
519
+ * same same-Session exclusivity rule as `call()` for THIS Session's own
520
+ * DHT-querying use (the separate dialed connection this opens
521
+ * internally is not this Session and is never exposed as one). */
522
+ async callDirect(procedure, payload, opts = {}) {
523
+ const handle = this.#requireHandleNotServing("callDirect");
524
+ const timeoutMs = opts.deadlineMs ?? DEFAULT_CALL_TIMEOUT_MS;
525
+ const payloadJson = JSON.stringify(payload ?? null);
526
+ const realm = realmBytesFromHex(opts.realm);
527
+ const envelopeJson = await this.#enqueue(() => native.directdialCall(handle, this.#identity.handleForFfi(), procedure, realm, payloadJson, timeoutMs));
528
+ const envelope = JSON.parse(envelopeJson);
529
+ if (envelope.ok)
530
+ return envelope.payload;
531
+ throw new MaculaCallError(envelope.bolt4);
532
+ }
533
+ /** Direct-dial (caller side): `callDirect()`, attaching `ucanToken` to
534
+ * the outgoing CALL (macula-go's `directdial.CallWithUCAN`) -- for
535
+ * reaching a direct-dial-advertised procedure a provider has gated
536
+ * behind a `ucan.Policy.Required` policy. Every hecate-om capability is
537
+ * advertised via `advertiseDirect()` specifically so it's reachable
538
+ * ONLY this way -- plain `callDirect()` cannot resolve or attach a
539
+ * token to it. Same `ucanToken` shape, no-audience-check, and
540
+ * resolve/reject conventions as `callWithUcan()` (see both that
541
+ * method's and ucan.ts's own doc for why: macula's UCAN gate is a
542
+ * bearer-token check, not an audience match).
543
+ *
544
+ * Same I/O and exclusivity notes as `callDirect()`. */
545
+ async callDirectWithUcan(procedure, payload, ucanToken, opts = {}) {
546
+ const handle = this.#requireHandleNotServing("callDirectWithUcan");
547
+ const timeoutMs = opts.deadlineMs ?? DEFAULT_CALL_TIMEOUT_MS;
548
+ const payloadJson = JSON.stringify(payload ?? null);
549
+ const realm = realmBytesFromHex(opts.realm);
550
+ const token = typeof ucanToken === "string" ? ucanToken : ucanToken.token;
551
+ const envelopeJson = await this.#enqueue(() => native.directdialCallWithUcan(handle, this.#identity.handleForFfi(), procedure, realm, payloadJson, timeoutMs, token));
552
+ const envelope = JSON.parse(envelopeJson);
553
+ if (envelope.ok)
554
+ return envelope.payload;
555
+ throw new MaculaCallError(envelope.bolt4);
556
+ }
557
+ /** Direct-dial (provider side): publishes `procedure` as
558
+ * direct-dial-reachable at THIS Session's own currently-connected
559
+ * station (macula-go's `directdial.AdvertiseDirect`) -- a plain
560
+ * ADVERTISE (so an inbound CALL routed here via the DHT-resolved path
561
+ * still has something to route to -- a real bug macula-go fixed live
562
+ * 2026-08-30: skipping this let resolve+dial complete cleanly against a
563
+ * station with nothing registered to answer, `ServeOneCall` never
564
+ * seeing it) plus a signed `procedure_advertisement` DHT record naming
565
+ * this Session's own station.
566
+ *
567
+ * Unlike `serve()`'s own internal advertise (still all-zero-realm-only
568
+ * in this SDK -- see `serve()`'s own doc), this method threads
569
+ * `opts.realm` all the way through, matching `directdial.AdvertiseDirect`
570
+ * itself: `resolveDirect()`/`callDirect()`/`callDirectWithUcan()` only
571
+ * ever reach a procedure `advertiseDirect()`d under the EXACT SAME
572
+ * realm.
573
+ *
574
+ * A station's registration for a procedure does not survive the
575
+ * connection that sent it being replaced -- a long-lived provider needs
576
+ * to call this again on its own schedule; see `keepAdvertisedDirect()`
577
+ * (directdial.ts) for that loop, built on top of this method rather
578
+ * than duplicating macula-go's own `KeepAdvertisedDirect` here.
579
+ *
580
+ * **Must NOT be called on a Session that is also actively
581
+ * `serve()`-ing** -- enforced by the same `#requireHandleNotServing`
582
+ * guard `call()`/the DHT methods use, for the identical reason: this
583
+ * method's own `PutRecord` CALL reads a RESULT off the same shared
584
+ * control stream `serve()`'s poll loop is also reading, and the two
585
+ * would race (matches `directdial.AdvertiseDirect`'s own doc). A
586
+ * provider that also serves `procedure` needs a SEPARATE Session (and
587
+ * identity -- this fleet enforces one connection per identity, kicking
588
+ * whichever connects second) to call this on.
589
+ *
590
+ * Real network I/O (a fire-and-forget ADVERTISE write plus a signed
591
+ * PutRecord CALL) -- off the main thread on the native side. */
592
+ async advertiseDirect(procedure, opts = {}) {
593
+ const handle = this.#requireHandleNotServing("advertiseDirect");
594
+ const realm = realmBytesFromHex(opts.realm);
595
+ await this.#enqueue(() => native.directdialAdvertise(handle, this.#identity.handleForFfi(), realm, procedure, opts.ttlMs ?? DHT_DEFAULT_TTL_MS));
596
+ }
597
+ /** Pubsub: sends a signed PUBLISH for `topic` (macula-go's
598
+ * connection.Session.Publish, which also attaches the end-to-end
599
+ * publisher_sig a relayed EVENT needs to survive beyond one hop --
600
+ * see that method's own doc, not reimplemented here). `payload`
601
+ * follows the same JsonValue rules as call()'s payload (no boolean,
602
+ * embedded bytes as "0x"-prefixed hex). Fire-and-forget: Publish's
603
+ * own doc is explicit that no reply is expected on the wire, so the
604
+ * returned Promise resolving only means this Session's own frame was
605
+ * encoded, signed, and sent -- never that any subscriber received it
606
+ * (macula-go's own live test for this, TestLivePubSubRoundTrip,
607
+ * observes a subscriber's own publish arriving back at it rather than
608
+ * asserting it as a hard guarantee, for the same reason).
609
+ *
610
+ * Deliberately NOT guarded by the same-Session exclusivity rule
611
+ * call()/serve()/subscribe()/the DHT methods share (see
612
+ * #requireHandleNotServing's own doc) -- publish() only ever writes,
613
+ * never reads off the shared control stream, so it does not race a
614
+ * concurrent serve()/subscribe() the way those do, and can run safely
615
+ * on the SAME Session a subscribe() of its own is active on -- exactly
616
+ * what a subscriber publishing to (and receiving) its own topic needs.
617
+ *
618
+ * `opts.realm` (see CallOptions.realm's own doc for the hex-string
619
+ * format and exact-match semantics) scopes which realm this EVENT is
620
+ * published under -- omitted means the all-zero realm, this SDK's
621
+ * sole default before this option existed. A subscribe() only
622
+ * receives this event if its own realm matches exactly.
623
+ *
624
+ * Real network I/O (one signed frame write) -- runs off the main
625
+ * thread on the native side, like every other network-touching method
626
+ * here. */
627
+ async publish(topic, payload, opts = {}) {
628
+ const handle = this.#requireHandle();
629
+ const payloadJson = JSON.stringify(payload ?? null);
630
+ const realm = realmBytesFromHex(opts.realm);
631
+ await native.sessionPublish(handle, this.#identity.handleForFfi(), realm, topic, payloadJson, opts.ttlMs ?? 0);
632
+ }
633
+ /** Pubsub: sends a signed SUBSCRIBE for `topic`, then delivers every
634
+ * inbound EVENT for it to `handler` -- macula-go's own
635
+ * connection.Session.RunSubscriber (connection/subscriber.go) drives
636
+ * the actual read loop on the Go side, in a background goroutine, NOT
637
+ * reimplemented on top of a hand-rolled poll here (see cabi/pubsub.go's
638
+ * own doc for why RunSubscriber specifically, over the lower-level
639
+ * RecvEvent). Delivery is Go-driven, not JS-driven: unlike serve()'s
640
+ * poll loop, nothing on this side calls into the native layer
641
+ * repeatedly to ask "did anything arrive yet" -- the addon calls INTO
642
+ * this handler asynchronously, via a Napi::ThreadSafeFunction wired to
643
+ * that background goroutine, whenever an EVENT actually shows up.
644
+ *
645
+ * Only one subscribe() (and no active serve()) is allowed per Session
646
+ * at a time -- same reasoning as serve()'s own one-at-a-time rule
647
+ * (#requireHandleNotServing's own doc): the background reader and any
648
+ * other read off this Session's shared control stream would race.
649
+ * Open a second Session for a second topic (or to serve/call
650
+ * concurrently) instead.
651
+ *
652
+ * Resolves with an async stop() function once the initial SUBSCRIBE
653
+ * has been sent and the background reader has started. stop() sends
654
+ * the matching UNSUBSCRIBE and does not resolve until the Go-side
655
+ * reader goroutine has genuinely exited -- calling it and awaiting the
656
+ * result is the actual guarantee that no further `handler` call can
657
+ * happen afterward, not just that one was requested.
658
+ *
659
+ * Real network I/O (the initial SUBSCRIBE send, and stop()'s
660
+ * UNSUBSCRIBE) -- both run off the main thread on the native side.
661
+ *
662
+ * `opts.realm` (see CallOptions.realm's own doc for the hex-string
663
+ * format and exact-match semantics) scopes which realm this SUBSCRIBE
664
+ * listens on -- omitted means the all-zero realm, this SDK's sole
665
+ * default before this option existed. Only an EVENT published under
666
+ * the SAME realm is ever delivered to `handler`.
667
+ *
668
+ * If the underlying connection dies (or any other transport error
669
+ * ends the background reader) rather than the returned stop() being
670
+ * called, this subscription tears itself down automatically -- the
671
+ * native handle is released and this Session is left closable and
672
+ * reusable for a fresh subscribe()/serve()/call() -- and, if
673
+ * provided, `opts.onClosed` is called once with the error. Verified
674
+ * live that, without this, such a subscription went silent forever
675
+ * (no further events, no error) and left this Session's handle
676
+ * permanently open even after close(). */
677
+ async subscribe(topic, handler, opts = {}) {
678
+ if (this.#activeServe !== null) {
679
+ throw new Error(`macula-ts: Session.subscribe() while serve("${this.#activeServe.procedure}") is active on the same ` +
680
+ `Session races on the shared control stream -- open a second Session for the other role.`);
681
+ }
682
+ if (this.#activeSubscription !== null) {
683
+ throw new Error(`macula-ts: Session is already subscribed to "${this.#activeSubscription.topic}" -- macula-go's ` +
684
+ `RunSubscriber reads one frame at a time off the shared control stream, so a second concurrent ` +
685
+ `subscribe() on the same Session races; open a second Session instead.`);
686
+ }
687
+ const handle = this.#requireHandle();
688
+ const identityHandle = this.#identity.handleForFfi();
689
+ const realm = realmBytesFromHex(opts.realm);
690
+ // Marked BEFORE the subscribe-start await below, not after -- same
691
+ // race-window fix as serve()'s own placeholder above, and for the
692
+ // identical reason (this repo's own live testing found the same
693
+ // class of race on both). Rolled back to null if starting the
694
+ // subscription itself fails.
695
+ const placeholderStop = async () => {
696
+ throw new Error(`macula-ts: session.subscribe("${topic}") has not finished starting yet`);
697
+ };
698
+ this.#activeSubscription = { topic, stop: placeholderStop };
699
+ // subscriptionHandle is assigned once (right after sessionSubscribeStart
700
+ // resolves, below) and only ever READ from here on -- realStop
701
+ // cannot run before that assignment, since nothing can call stop()
702
+ // (directly or via onClosed) before this function itself returns it.
703
+ let subscriptionHandle;
704
+ let stopPromise = null;
705
+ const realStop = async () => {
706
+ this.#activeSubscription = null;
707
+ await this.#enqueue(() => native.sessionSubscribeStop(subscriptionHandle));
708
+ };
709
+ // Memoized so it is safe to call more than once, from more than one
710
+ // place -- the caller's own returned stop(), AND onClosed's own
711
+ // internal call below when the reader exits on its own -- without
712
+ // either double-invoking the native stop (which would either be a
713
+ // wasted call or, worse, race a second subscribe() that had since
714
+ // reused this Session). Whichever caller gets here first actually
715
+ // runs realStop(); everyone else gets that same settled outcome.
716
+ const stop = () => {
717
+ if (stopPromise === null)
718
+ stopPromise = realStop();
719
+ return stopPromise;
720
+ };
721
+ const onClosed = (error) => {
722
+ console.error(`macula-ts: session.subscribe("${topic}") ended unexpectedly, tearing it down:`, error);
723
+ // This internal call's own rejection (the underlying transport
724
+ // error realStop's native call surfaces once more, tearing down
725
+ // an already-dead subscription) is not new information -- error
726
+ // is already being reported via onClosed itself, right below.
727
+ // A caller's OWN explicit call to the returned stop() afterward
728
+ // still resolves/rejects for real, from the same memoized promise.
729
+ stop().catch(() => { });
730
+ opts.onClosed?.(error);
731
+ };
732
+ try {
733
+ subscriptionHandle = await this.#enqueue(() => native.sessionSubscribeStart(handle, identityHandle, realm, topic, (msg) => {
734
+ if (msg.kind === "closed") {
735
+ onClosed(new Error(msg.error ?? "subscription closed"));
736
+ return;
737
+ }
738
+ handler({ payload: JSON.parse(msg.payloadJson), publisher: msg.publisher, seq: msg.seq });
739
+ }));
740
+ }
741
+ catch (err) {
742
+ this.#activeSubscription = null;
743
+ throw err;
744
+ }
745
+ this.#activeSubscription = { topic, stop };
746
+ return stop;
747
+ }
748
+ /** Content transfer: stores `data` (macula-go's content.Put, on this
749
+ * Session's own fresh dedicated QUIC stream -- Session.
750
+ * OpenDedicatedStream on the Go side, NOT the shared control stream
751
+ * call()/serve()/the DHT methods/subscribe() all read from), chunking
752
+ * automatically above manifest.DefaultChunkSize (256 KiB) and
753
+ * returning the hex-encoded mcid it's now addressable by. `name` is
754
+ * used ONLY on the chunked path (attached to the resulting manifest)
755
+ * -- a single-block put ignores it entirely, matching content.Put's
756
+ * own documented behavior; leave it unset for small blobs.
757
+ *
758
+ * NOT durable object storage -- see content.ts's own module doc: a
759
+ * station may forget this content later, and there is no list/delete
760
+ * operation. Treat this as "hand these bytes to a peer once."
761
+ *
762
+ * Because Put opens its own dedicated stream instead of reading the
763
+ * shared control stream, this is, unlike call()/serve()/the DHT
764
+ * methods/subscribe(), never subject to Session's same-Session
765
+ * exclusivity guard (#requireHandleNotServing) -- it can run
766
+ * concurrently with an active serve()/subscribe() (or another
767
+ * putContent()/getContent()) on the same Session without racing.
768
+ *
769
+ * Real network I/O (one or more signed CALLs on the new stream) --
770
+ * runs off the main thread on the native side, like every other
771
+ * network-touching method here. */
772
+ async putContent(data, name = "") {
773
+ const handle = this.#requireHandle();
774
+ const mcid = await native.contentPut(handle, this.#identity.handleForFfi(), data, name);
775
+ return { mcid };
776
+ }
777
+ /** Content transfer: fetches and verifies (macula-go's content.Get,
778
+ * on its own fresh dedicated QUIC stream, same reasoning as
779
+ * putContent() -- including content.Get's own client-side hash
780
+ * re-check against `mcid`: a station may only be relaying content it
781
+ * doesn't itself store, so its answer is never trusted blindly) the
782
+ * content addressed by `mcid` (the hex string putContent() returned).
783
+ *
784
+ * Rejects with ContentNotFoundError (content.ts) specifically when
785
+ * the station reports it doesn't know this mcid -- an expected,
786
+ * routine outcome for a one-time transfer mechanism with no
787
+ * durability guarantee, not a transport failure; every other failure
788
+ * (a bad session, a malformed mcid, a real transport error) rejects
789
+ * with a plain Error instead.
790
+ *
791
+ * Same dedicated-stream, no-exclusivity-guard reasoning as
792
+ * putContent() -- safe alongside an active serve()/subscribe() on the
793
+ * same Session. Real network I/O, runs off the main thread. */
794
+ async getContent(mcid) {
795
+ const handle = this.#requireHandle();
796
+ const data = await native.contentGet(handle, this.#identity.handleForFfi(), mcid);
797
+ if (data === null)
798
+ throw new ContentNotFoundError(mcid);
799
+ return data;
800
+ }
801
+ }
802
+ function requireKey32(key) {
803
+ if (key.length !== 32) {
804
+ throw new Error(`macula-ts: DHT key must be exactly 32 bytes, got ${key.length}`);
805
+ }
806
+ }
807
+ //# sourceMappingURL=session.js.map