@macula-io/ts 0.18.0 → 0.20.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.
package/dist/session.js DELETED
@@ -1,829 +0,0 @@
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 { bytesModeFor, 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
- export 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 go in as `{"$bytes": base64}` and
201
- * come back as `opts.bytes` asks, hex by default).
202
- *
203
- * Resolves with the RESULT's payload on success. Rejects with a
204
- * MaculaCallError (rpc.ts) when a real BOLT#4 ERROR frame came back
205
- * instead -- e.g. `unknown_next_peer` for a procedure nobody has
206
- * advertised -- carrying its code/name/retryable triple rather than
207
- * a generic message; rejects with a plain Error for everything that
208
- * isn't a wire-level answer at all (a local timeout, a dead session,
209
- * a payload the wire can't represent).
210
- *
211
- * Real network I/O -- a signed frame out and a wait for the reply,
212
- * up to opts.deadlineMs -- so this runs off the main thread on the
213
- * native side, like connect()/close(). Do not call this
214
- * concurrently with an active serve() on the SAME Session: both read
215
- * frames off the one shared control stream, and macula-go's own
216
- * ServeOneCall/Call docs both warn that mixing roles on one
217
- * connection races (an unrelated frame arriving first is discarded,
218
- * not queued) -- open a second Session for the other role instead,
219
- * exactly what this SDK's own live test does. */
220
- async call(procedure, payload, opts = {}) {
221
- const handle = this.#requireHandleNotServing("call");
222
- const timeoutMs = opts.deadlineMs ?? DEFAULT_CALL_TIMEOUT_MS;
223
- const payloadJson = JSON.stringify(payload ?? null);
224
- const realm = realmBytesFromHex(opts.realm);
225
- const bytesMode = bytesModeFor(opts.bytes);
226
- const envelopeJson = await this.#enqueue(() => native.sessionCall(handle, this.#identity.handleForFfi(), procedure, realm, payloadJson, timeoutMs, bytesMode));
227
- const envelope = JSON.parse(envelopeJson);
228
- if (envelope.ok)
229
- return envelope.payload;
230
- throw new MaculaCallError(envelope.bolt4);
231
- }
232
- /** Caller role: call(), attaching `ucanToken` to the outgoing CALL
233
- * (macula-go's `connection.Session.CallWithUCAN`) -- for invoking a
234
- * procedure a provider has gated behind a `ucan.Policy.Required`
235
- * policy on its own side (this SDK does not implement that provider
236
- * side itself -- see ucan.ts's own module doc). `ucanToken` may be a
237
- * `Ucan` (as returned by `Ucan.mint()`/`Ucan.decode()` -- its `.token`
238
- * is attached) or a raw token string directly.
239
- *
240
- * Same resolve/reject shape as `call()` in every other respect: the
241
- * RESULT's payload on success, a `MaculaCallError` for a real BOLT#4
242
- * ERROR frame (e.g. `unauthorized` for a token that fails the
243
- * provider's policy check, or `unknown_next_peer` for a procedure
244
- * nobody has advertised), a plain `Error` for anything that never got
245
- * a wire-level answer at all.
246
- *
247
- * Deliberately attaches whatever token bytes it is given with NO local
248
- * check relating this Session's own identity to `ucanToken`'s `aud`
249
- * claim -- see ucan.ts's own module doc for why: macula's UCAN gate is
250
- * a bearer-token check (signature + expiry against the token's
251
- * issuer), and the real wire-level gate never looks at the caller's
252
- * identity against `aud` either. A client-side guard here would both
253
- * reject configurations the mesh accepts fine and misrepresent a
254
- * security property that isn't actually enforced.
255
- *
256
- * Real network I/O, off the main thread on the native side, and
257
- * subject to the identical same-Session exclusivity rule as `call()`
258
- * (`#requireHandleNotServing`) -- both end up on the same shared
259
- * control stream. */
260
- async callWithUcan(procedure, payload, ucanToken, opts = {}) {
261
- const handle = this.#requireHandleNotServing("callWithUcan");
262
- const timeoutMs = opts.deadlineMs ?? DEFAULT_CALL_TIMEOUT_MS;
263
- const payloadJson = JSON.stringify(payload ?? null);
264
- const realm = realmBytesFromHex(opts.realm);
265
- const token = typeof ucanToken === "string" ? ucanToken : ucanToken.token;
266
- const bytesMode = bytesModeFor(opts.bytes);
267
- const envelopeJson = await this.#enqueue(() => native.sessionCallWithUcan(handle, this.#identity.handleForFfi(), procedure, realm, payloadJson, timeoutMs, token, bytesMode));
268
- const envelope = JSON.parse(envelopeJson);
269
- if (envelope.ok)
270
- return envelope.payload;
271
- throw new MaculaCallError(envelope.bolt4);
272
- }
273
- /** Provider role: advertises `procedure` (macula-go's
274
- * connection.Session.Advertise) and answers inbound CALLs against it
275
- * forever, invoking `handler` for each one (payload in, reply or
276
- * thrown error out -- `handler` may be async), until the returned
277
- * stop function is called. A thrown/rejected `handler` becomes a
278
- * BOLT#4 UnknownError reply carrying the thrown value's message as
279
- * detail (matching macula-go's connection/serve.go, which maps every
280
- * handler error to that one code); a `handler` that panics on the Go
281
- * side instead (not reachable from here -- there is no Go code
282
- * between this and the JS handler) would map to
283
- * TemporaryRelayFailure, per that same file.
284
- *
285
- * Only one serve() registration is allowed per Session at a time --
286
- * see call()'s own doc on why mixing roles (or two server loops) on
287
- * one connection is unsafe, not just inadvisable; open a second
288
- * Session for a second procedure instead of trying to serve two
289
- * procedures off one.
290
- *
291
- * `opts.bytes` picks how bytes in each inbound CALL's payload reach
292
- * `handler`: "hex" (the default) or "tagged" (rpc.ts's BytesOutput).
293
- * A reply follows the same JsonValue rules as any payload, so a
294
- * tagged value can be returned as it arrived.
295
- *
296
- * The returned stop function is async: it unadvertises the
297
- * procedure (a real network write) and waits for the current poll
298
- * tick to finish (up to rpc.ts's SERVE_POLL_MS) before resolving --
299
- * there is no way to interrupt a Go-side wait already in flight, the
300
- * same bounded-latency shape macula-go's own ServeForever has
301
- * internally. */
302
- async serve(procedure, handler, opts = {}) {
303
- const bytesMode = bytesModeFor(opts.bytes);
304
- if (this.#activeServe !== null) {
305
- throw new Error(`macula-ts: Session is already serving "${this.#activeServe.procedure}" -- macula-go's ServeOneCall reads ` +
306
- `one frame at a time off the shared control stream, so a second concurrent serve() (or a serve() ` +
307
- `alongside call()) on the same Session races; open a second Session instead.`);
308
- }
309
- if (this.#activeSubscription !== null) {
310
- throw new Error(`macula-ts: Session.serve() while subscribe("${this.#activeSubscription.topic}") is active on the same ` +
311
- `Session races on the shared control stream -- open a second Session for the other role.`);
312
- }
313
- const handle = this.#requireHandle();
314
- const identityHandle = this.#identity.handleForFfi();
315
- // Marked BEFORE the advertise await below, not after -- closing the
316
- // exact race window this repo's own live testing found: a
317
- // concurrent call()/serve()/subscribe() checks #activeServe
318
- // synchronously, so it must already be non-null by the time this
319
- // function's first await yields control, not only once advertise
320
- // has finished. The placeholder stop() is only reachable if
321
- // something races this same synchronous tick (impossible in
322
- // practice, single-threaded JS) or misuses the handle before
323
- // startup finishes; rolled back to null below if advertise itself
324
- // fails, so a failed serve() attempt doesn't leave the Session
325
- // permanently (and incorrectly) marked as serving.
326
- const placeholderStop = async () => {
327
- throw new Error(`macula-ts: session.serve("${procedure}") has not finished starting yet`);
328
- };
329
- this.#activeServe = { procedure, stop: placeholderStop };
330
- try {
331
- await this.#enqueue(() => native.sessionAdvertise(handle, identityHandle, undefined, procedure));
332
- }
333
- catch (err) {
334
- this.#activeServe = null;
335
- throw err;
336
- }
337
- let stopped = false;
338
- const loopDone = (async () => {
339
- while (!stopped) {
340
- let pendingHandle;
341
- try {
342
- pendingHandle = await this.#enqueue(() => native.serveWaitForCall(handle, identityHandle, undefined, procedure, SERVE_POLL_MS));
343
- }
344
- catch (err) {
345
- if (!stopped) {
346
- console.error(`macula-ts: session.serve("${procedure}") poll failed, stopping this server loop:`, err);
347
- }
348
- return;
349
- }
350
- if (pendingHandle === null)
351
- continue; // nothing arrived this tick -- poll again
352
- const payload = JSON.parse(native.pendingCallPayloadJson(pendingHandle, bytesMode));
353
- try {
354
- const reply = await handler(payload);
355
- await native.pendingCallReplyResult(pendingHandle, JSON.stringify(reply ?? null));
356
- }
357
- catch (err) {
358
- const detail = err instanceof Error ? err.message : String(err);
359
- try {
360
- await native.pendingCallReplyError(pendingHandle, detail);
361
- }
362
- catch (replyErr) {
363
- console.error(`macula-ts: session.serve("${procedure}") failed to send a reply:`, replyErr);
364
- }
365
- }
366
- }
367
- })();
368
- const stop = async () => {
369
- stopped = true;
370
- await loopDone;
371
- this.#activeServe = null;
372
- if (this.#handle !== null) {
373
- await this.#enqueue(() => native.sessionUnadvertise(handle, identityHandle, undefined, procedure));
374
- }
375
- };
376
- this.#activeServe = { procedure, stop };
377
- return stop;
378
- }
379
- /** DHT: returns every record of `recordType` currently visible from
380
- * the station this Session is connected to (macula-go's
381
- * dht.FindRecordsByType, via a signed CALL to `_dht.find_records_by_type`
382
- * under the DHT's own reserved realm -- threaded internally, this
383
- * method never touches a realm itself). Coverage depends on that
384
- * station's own view of the mesh, not a global guarantee. Neither
385
- * this nor findRecord/findRecords verifies a returned record's
386
- * signature or checks its expiry -- see DhtRecord's own doc (dht.ts).
387
- *
388
- * Real network I/O, off the main thread on the native side, exactly
389
- * like call() -- and subject to the same same-Session exclusivity
390
- * rule as call() (see #requireHandleNotServing's own doc): do not
391
- * call this while serve() is active on the same Session. */
392
- async findRecordsByType(recordType) {
393
- const handle = this.#requireHandleNotServing("findRecordsByType");
394
- const json = await this.#enqueue(() => native.dhtFindRecordsByType(handle, this.#identity.handleForFfi(), recordType));
395
- return JSON.parse(json);
396
- }
397
- /** DHT: returns every record stored at `key` -- the full
398
- * signer-deduped multiset at that storage key (macula-go's
399
- * dht.FindRecords), e.g. every procedure_advertisement for one
400
- * procedure, not just the first one found. `key` must be exactly 32
401
- * bytes -- see dht/record.go's ProcedureKey/StationEndpointKey/
402
- * ContentKey (macula-go) for how those are derived from the thing
403
- * being looked up. Same I/O and exclusivity notes as
404
- * findRecordsByType(). */
405
- async findRecords(key) {
406
- const handle = this.#requireHandleNotServing("findRecords");
407
- requireKey32(key);
408
- const json = await this.#enqueue(() => native.dhtFindRecords(handle, this.#identity.handleForFfi(), key));
409
- return JSON.parse(json);
410
- }
411
- /** DHT: returns ONE record by storage key (macula-go's
412
- * dht.FindRecord). Resolves `null` when none exists -- mirrors
413
- * macula-go's own dht.ErrNotFound, translated to a value instead of a
414
- * thrown error since "not found" is an expected, routine outcome
415
- * here, not exceptional. Same I/O and exclusivity notes as
416
- * findRecordsByType(). */
417
- async findRecord(key) {
418
- const handle = this.#requireHandleNotServing("findRecord");
419
- requireKey32(key);
420
- const json = await this.#enqueue(() => native.dhtFindRecord(handle, this.#identity.handleForFfi(), key));
421
- return json === null ? null : JSON.parse(json);
422
- }
423
- /** DHT: builds the realm-qualified discovery URI (macula-go's
424
- * dht.DiscoveryURI), then builds (dht.NewProcedureAdvertisement),
425
- * signs, and stores a procedure_advertisement naming this Session's
426
- * own Identity as `procedure`'s advertiser and `servingStation` (32
427
- * bytes -- a station's NodeID, typically this Session's own
428
- * `stationNodeId`) as the station that serves it.
429
- *
430
- * `realm` should be the SAME realm `procedure` is (or will be) served
431
- * under via `serve()` -- defaults to the all-zero realm, matching
432
- * `call()`'s own default (see CallOptions.realm's own doc: `call()`/
433
- * `callWithUcan()`/`publish()`/`subscribe()` now all take an optional
434
- * realm; `serve()`/`advertise` remain all-zero-realm-only for this
435
- * slice, unchanged). This method
436
- * builds the qualified URI itself (dht.DiscoveryURI) rather than
437
- * taking a pre-qualified string, since NewProcedureAdvertisement's own
438
- * doc is explicit that "the advertiser and the resolver must derive
439
- * the identical URI or the DHT storage key will not agree" -- a
440
- * caller-supplied pre-qualified string invites exactly that class of
441
- * bug (verified directly: an earlier draft of this SDK's own live
442
- * test got this wrong by hand-qualifying the URI itself, and its
443
- * following findRecord() came back not-found until this method built
444
- * the URI internally instead).
445
- *
446
- * Wraps macula-go's REAL constructor rather than a generic
447
- * JSON-payload builder deliberately -- see cabi/dht.go's own doc on
448
- * why: two of this record type's payload fields (advertiser_node,
449
- * serving_station) are raw 32-byte pubkeys that must be actual CBOR
450
- * byte strings for a real resolver to read, and this SDK's generic
451
- * JSON<->cbor.Value conversion (rpc.ts's JsonValue, wirevalue.go) has
452
- * no way to guarantee that on its own: it produces them only when
453
- * every caller tags both as `{"$bytes": base64}`, and a plain string
454
- * silently becomes CBOR text. Typed arguments rule that mistake out
455
- * (see DhtRecord's own doc). `ttlMs` defaults to DHT_DEFAULT_TTL_MS
456
- * (48h). Resolves with the signed record actually stored. Same I/O
457
- * and exclusivity notes as findRecordsByType(). */
458
- async putProcedureAdvertisement(procedure, servingStation, opts = {}) {
459
- const handle = this.#requireHandleNotServing("putProcedureAdvertisement");
460
- requireKey32(servingStation);
461
- if (opts.realm !== undefined)
462
- requireKey32(opts.realm);
463
- const json = await this.#enqueue(() => native.dhtPutProcedureAdvertisement(handle, this.#identity.handleForFfi(), opts.realm, procedure, servingStation, opts.ttlMs ?? DHT_DEFAULT_TTL_MS));
464
- return JSON.parse(json);
465
- }
466
- /** DHT: builds (macula-go's dht.NewContentAnnouncement), signs, and
467
- * stores a content_announcement naming this Session's own Identity as
468
- * `mcid`'s (34 bytes) announcer, reachable at `endpoint` (a dialable
469
- * seed URL, e.g. "https://host:4433" -- NOT a station_endpoint's
470
- * split host/port). Same reasoning as putProcedureAdvertisement()'s
471
- * own doc for wrapping macula-go's real constructor instead of a
472
- * generic JSON-payload builder (announcer_node/mcid are the same kind
473
- * of raw-byte field). `ttlMs` defaults to DHT_DEFAULT_TTL_MS (48h).
474
- * Same I/O and exclusivity notes as findRecordsByType(). */
475
- async putContentAnnouncement(mcid, endpoint, ttlMs = DHT_DEFAULT_TTL_MS) {
476
- const handle = this.#requireHandleNotServing("putContentAnnouncement");
477
- if (mcid.length !== 34) {
478
- throw new Error(`macula-ts: content_announcement mcid must be exactly 34 bytes, got ${mcid.length}`);
479
- }
480
- const json = await this.#enqueue(() => native.dhtPutContentAnnouncement(handle, this.#identity.handleForFfi(), mcid, endpoint, ttlMs));
481
- return JSON.parse(json);
482
- }
483
- /** Direct-dial (caller side): finds `procedure`'s currently-advertised
484
- * serving station and its dialable host/port (macula-go's
485
- * `directdial.Resolve`), via this Session used only to query the DHT
486
- * -- it does not need to be connected to the station that ends up
487
- * serving `procedure`. Every advertisement that verifies is a
488
- * candidate, and the DHT is asked again until one's station endpoint
489
- * resolves or `opts.deadlineMs` passes (10 s when unset, macula-go's
490
- * `directdial.DefaultResolveTimeout`); a `procedure` nobody ever called
491
- * `advertiseDirect()` for rejects once that time is up (a plain `Error`
492
- * wrapping macula-go's `ErrProcedureNotAdvertised`), never a hang.
493
- *
494
- * `opts.realm` must match whatever realm `procedure` was
495
- * `advertiseDirect()`d under, or the discovery URI the two sides
496
- * derive disagrees and this rejects the same way as if nothing was
497
- * ever advertised at all -- see `AdvertiseDirectOptions.realm`'s own
498
- * doc (directdial.ts). `callDirect()`/`callDirectWithUcan()` call this
499
- * internally; it's exposed on its own for callers that just want the
500
- * resolved station/host/port without also dialing and calling it (e.g.
501
- * diagnostics).
502
- *
503
- * Real network I/O, off the main thread on the native side, subject to
504
- * the same same-Session exclusivity rule as `call()`/the DHT methods
505
- * (`#requireHandleNotServing`). */
506
- async resolveDirect(procedure, opts = {}) {
507
- if (opts.deadlineMs !== undefined && !(opts.deadlineMs > 0)) {
508
- throw new RangeError(`macula-ts: resolveDirect deadlineMs must be a positive number of milliseconds, got ${opts.deadlineMs}`);
509
- }
510
- const handle = this.#requireHandleNotServing("resolveDirect");
511
- const realm = realmBytesFromHex(opts.realm);
512
- const deadlineMs = opts.deadlineMs ?? 0;
513
- const json = await this.#enqueue(() => native.directdialResolve(handle, this.#identity.handleForFfi(), realm, procedure, deadlineMs));
514
- return JSON.parse(json);
515
- }
516
- /** Direct-dial (caller side): resolves `procedure`'s provider (via
517
- * `resolveDirect()`, through this Session) and calls it there, in one
518
- * hop, in a SEPARATE connection macula-go opens, application-layer-pins
519
- * against the resolved station identity, and closes again, entirely
520
- * internally (macula-go's `directdial.Call`) -- this Session's own
521
- * connection is never touched beyond the DHT lookup. The provider must
522
- * have `advertiseDirect()`d `procedure` (or the Erlang/Rust/Go
523
- * equivalent) -- a plain `serve()`-side `Advertise` alone publishes no
524
- * discoverable DHT record, and this rejects with `ErrProcedureNotAdvertised`.
525
- *
526
- * Same resolve/reject shape as `call()`: the RESULT's payload on
527
- * success, a `MaculaCallError` (rpc.ts) for a real BOLT#4 ERROR frame
528
- * from the resolved provider, a plain `Error` for a resolve failure, a
529
- * dial failure, an identity-trust violation (the dialed peer proved a
530
- * DIFFERENT identity than the DHT chain resolved), or anything else
531
- * that never got a wire-level answer at all.
532
- *
533
- * Real network I/O (DHT lookups, a fresh QUIC dial, then the CALL
534
- * itself) -- off the main thread on the native side, subject to the
535
- * same same-Session exclusivity rule as `call()` for THIS Session's own
536
- * DHT-querying use (the separate dialed connection this opens
537
- * internally is not this Session and is never exposed as one). */
538
- async callDirect(procedure, payload, opts = {}) {
539
- const handle = this.#requireHandleNotServing("callDirect");
540
- const timeoutMs = opts.deadlineMs ?? DEFAULT_CALL_TIMEOUT_MS;
541
- const payloadJson = JSON.stringify(payload ?? null);
542
- const realm = realmBytesFromHex(opts.realm);
543
- const bytesMode = bytesModeFor(opts.bytes);
544
- const envelopeJson = await this.#enqueue(() => native.directdialCall(handle, this.#identity.handleForFfi(), procedure, realm, payloadJson, timeoutMs, bytesMode));
545
- const envelope = JSON.parse(envelopeJson);
546
- if (envelope.ok)
547
- return envelope.payload;
548
- throw new MaculaCallError(envelope.bolt4);
549
- }
550
- /** Direct-dial (caller side): `callDirect()`, attaching `ucanToken` to
551
- * the outgoing CALL (macula-go's `directdial.CallWithUCAN`) -- for
552
- * reaching a direct-dial-advertised procedure a provider has gated
553
- * behind a `ucan.Policy.Required` policy. Every hecate-om capability is
554
- * advertised via `advertiseDirect()` specifically so it's reachable
555
- * ONLY this way -- plain `callDirect()` cannot resolve or attach a
556
- * token to it. Same `ucanToken` shape, no-audience-check, and
557
- * resolve/reject conventions as `callWithUcan()` (see both that
558
- * method's and ucan.ts's own doc for why: macula's UCAN gate is a
559
- * bearer-token check, not an audience match).
560
- *
561
- * Same I/O and exclusivity notes as `callDirect()`. */
562
- async callDirectWithUcan(procedure, payload, ucanToken, opts = {}) {
563
- const handle = this.#requireHandleNotServing("callDirectWithUcan");
564
- const timeoutMs = opts.deadlineMs ?? DEFAULT_CALL_TIMEOUT_MS;
565
- const payloadJson = JSON.stringify(payload ?? null);
566
- const realm = realmBytesFromHex(opts.realm);
567
- const token = typeof ucanToken === "string" ? ucanToken : ucanToken.token;
568
- const bytesMode = bytesModeFor(opts.bytes);
569
- const envelopeJson = await this.#enqueue(() => native.directdialCallWithUcan(handle, this.#identity.handleForFfi(), procedure, realm, payloadJson, timeoutMs, token, bytesMode));
570
- const envelope = JSON.parse(envelopeJson);
571
- if (envelope.ok)
572
- return envelope.payload;
573
- throw new MaculaCallError(envelope.bolt4);
574
- }
575
- /** Direct-dial (provider side): publishes `procedure` as
576
- * direct-dial-reachable at THIS Session's own currently-connected
577
- * station (macula-go's `directdial.AdvertiseDirect`) -- a plain
578
- * ADVERTISE (so an inbound CALL routed here via the DHT-resolved path
579
- * still has something to route to -- a real bug macula-go fixed live
580
- * 2026-08-30: skipping this let resolve+dial complete cleanly against a
581
- * station with nothing registered to answer, `ServeOneCall` never
582
- * seeing it) plus a signed `procedure_advertisement` DHT record naming
583
- * this Session's own station.
584
- *
585
- * Unlike `serve()`'s own internal advertise (still all-zero-realm-only
586
- * in this SDK -- see `serve()`'s own doc), this method threads
587
- * `opts.realm` all the way through, matching `directdial.AdvertiseDirect`
588
- * itself: `resolveDirect()`/`callDirect()`/`callDirectWithUcan()` only
589
- * ever reach a procedure `advertiseDirect()`d under the EXACT SAME
590
- * realm.
591
- *
592
- * A station's registration for a procedure does not survive the
593
- * connection that sent it being replaced -- a long-lived provider needs
594
- * to call this again on its own schedule; see `keepAdvertisedDirect()`
595
- * (directdial.ts) for that loop, built on top of this method rather
596
- * than duplicating macula-go's own `KeepAdvertisedDirect` here.
597
- *
598
- * **Must NOT be called on a Session that is also actively
599
- * `serve()`-ing** -- enforced by the same `#requireHandleNotServing`
600
- * guard `call()`/the DHT methods use, for the identical reason: this
601
- * method's own `PutRecord` CALL reads a RESULT off the same shared
602
- * control stream `serve()`'s poll loop is also reading, and the two
603
- * would race (matches `directdial.AdvertiseDirect`'s own doc). A
604
- * provider that also serves `procedure` needs a SEPARATE Session (and
605
- * identity -- this fleet enforces one connection per identity, kicking
606
- * whichever connects second) to call this on.
607
- *
608
- * Real network I/O (a fire-and-forget ADVERTISE write plus a signed
609
- * PutRecord CALL) -- off the main thread on the native side. */
610
- async advertiseDirect(procedure, opts = {}) {
611
- const handle = this.#requireHandleNotServing("advertiseDirect");
612
- const realm = realmBytesFromHex(opts.realm);
613
- await this.#enqueue(() => native.directdialAdvertise(handle, this.#identity.handleForFfi(), realm, procedure, opts.ttlMs ?? DHT_DEFAULT_TTL_MS));
614
- }
615
- /** Pubsub: sends a signed PUBLISH for `topic` (macula-go's
616
- * connection.Session.Publish, which also attaches the end-to-end
617
- * publisher_sig a relayed EVENT needs to survive beyond one hop --
618
- * see that method's own doc, not reimplemented here). `payload`
619
- * follows the same JsonValue rules as call()'s payload (no boolean,
620
- * embedded bytes as `{"$bytes": base64}`). Fire-and-forget: Publish's
621
- * own doc is explicit that no reply is expected on the wire, so the
622
- * returned Promise resolving only means this Session's own frame was
623
- * encoded, signed, and sent -- never that any subscriber received it
624
- * (macula-go's own live test for this, TestLivePubSubRoundTrip,
625
- * observes a subscriber's own publish arriving back at it rather than
626
- * asserting it as a hard guarantee, for the same reason).
627
- *
628
- * Deliberately NOT guarded by the same-Session exclusivity rule
629
- * call()/serve()/subscribe()/the DHT methods share (see
630
- * #requireHandleNotServing's own doc) -- publish() only ever writes,
631
- * never reads off the shared control stream, so it does not race a
632
- * concurrent serve()/subscribe() the way those do, and can run safely
633
- * on the SAME Session a subscribe() of its own is active on -- exactly
634
- * what a subscriber publishing to (and receiving) its own topic needs.
635
- *
636
- * `opts.realm` (see CallOptions.realm's own doc for the hex-string
637
- * format and exact-match semantics) scopes which realm this EVENT is
638
- * published under -- omitted means the all-zero realm, this SDK's
639
- * sole default before this option existed. A subscribe() only
640
- * receives this event if its own realm matches exactly.
641
- *
642
- * Real network I/O (one signed frame write) -- runs off the main
643
- * thread on the native side, like every other network-touching method
644
- * here. */
645
- async publish(topic, payload, opts = {}) {
646
- const handle = this.#requireHandle();
647
- const payloadJson = JSON.stringify(payload ?? null);
648
- const realm = realmBytesFromHex(opts.realm);
649
- await native.sessionPublish(handle, this.#identity.handleForFfi(), realm, topic, payloadJson, opts.ttlMs ?? 0);
650
- }
651
- /** Pubsub: sends a signed SUBSCRIBE for `topic`, then delivers every
652
- * inbound EVENT for it to `handler` -- macula-go's own
653
- * connection.Session.RunSubscriber (connection/subscriber.go) drives
654
- * the actual read loop on the Go side, in a background goroutine, NOT
655
- * reimplemented on top of a hand-rolled poll here (see cabi/pubsub.go's
656
- * own doc for why RunSubscriber specifically, over the lower-level
657
- * RecvEvent). Delivery is Go-driven, not JS-driven: unlike serve()'s
658
- * poll loop, nothing on this side calls into the native layer
659
- * repeatedly to ask "did anything arrive yet" -- the addon calls INTO
660
- * this handler asynchronously, via a Napi::ThreadSafeFunction wired to
661
- * that background goroutine, whenever an EVENT actually shows up.
662
- *
663
- * Only one subscribe() (and no active serve()) is allowed per Session
664
- * at a time -- same reasoning as serve()'s own one-at-a-time rule
665
- * (#requireHandleNotServing's own doc): the background reader and any
666
- * other read off this Session's shared control stream would race.
667
- * Open a second Session for a second topic (or to serve/call
668
- * concurrently) instead.
669
- *
670
- * Resolves with an async stop() function once the initial SUBSCRIBE
671
- * has been sent and the background reader has started. stop() sends
672
- * the matching UNSUBSCRIBE and does not resolve until the Go-side
673
- * reader goroutine has genuinely exited -- calling it and awaiting the
674
- * result is the actual guarantee that no further `handler` call can
675
- * happen afterward, not just that one was requested.
676
- *
677
- * Real network I/O (the initial SUBSCRIBE send, and stop()'s
678
- * UNSUBSCRIBE) -- both run off the main thread on the native side.
679
- *
680
- * `opts.realm` (see CallOptions.realm's own doc for the hex-string
681
- * format and exact-match semantics) scopes which realm this SUBSCRIBE
682
- * listens on -- omitted means the all-zero realm, this SDK's sole
683
- * default before this option existed. Only an EVENT published under
684
- * the SAME realm is ever delivered to `handler`.
685
- *
686
- * `opts.bytes` picks how bytes in each event's payload reach
687
- * `handler`: "hex" (the default) or "tagged" (rpc.ts's BytesOutput).
688
- *
689
- * If the underlying connection dies (or any other transport error
690
- * ends the background reader) rather than the returned stop() being
691
- * called, this subscription tears itself down automatically -- the
692
- * native handle is released and this Session is left closable and
693
- * reusable for a fresh subscribe()/serve()/call() -- and, if
694
- * provided, `opts.onClosed` is called once with the error. Verified
695
- * live that, without this, such a subscription went silent forever
696
- * (no further events, no error) and left this Session's handle
697
- * permanently open even after close(). */
698
- async subscribe(topic, handler, opts = {}) {
699
- if (this.#activeServe !== null) {
700
- throw new Error(`macula-ts: Session.subscribe() while serve("${this.#activeServe.procedure}") is active on the same ` +
701
- `Session races on the shared control stream -- open a second Session for the other role.`);
702
- }
703
- if (this.#activeSubscription !== null) {
704
- throw new Error(`macula-ts: Session is already subscribed to "${this.#activeSubscription.topic}" -- macula-go's ` +
705
- `RunSubscriber reads one frame at a time off the shared control stream, so a second concurrent ` +
706
- `subscribe() on the same Session races; open a second Session instead.`);
707
- }
708
- const handle = this.#requireHandle();
709
- const identityHandle = this.#identity.handleForFfi();
710
- const realm = realmBytesFromHex(opts.realm);
711
- const bytesMode = bytesModeFor(opts.bytes);
712
- // Marked BEFORE the subscribe-start await below, not after -- same
713
- // race-window fix as serve()'s own placeholder above, and for the
714
- // identical reason (this repo's own live testing found the same
715
- // class of race on both). Rolled back to null if starting the
716
- // subscription itself fails.
717
- const placeholderStop = async () => {
718
- throw new Error(`macula-ts: session.subscribe("${topic}") has not finished starting yet`);
719
- };
720
- this.#activeSubscription = { topic, stop: placeholderStop };
721
- // subscriptionHandle is assigned once (right after sessionSubscribeStart
722
- // resolves, below) and only ever READ from here on -- realStop
723
- // cannot run before that assignment, since nothing can call stop()
724
- // (directly or via onClosed) before this function itself returns it.
725
- let subscriptionHandle;
726
- let stopPromise = null;
727
- const realStop = async () => {
728
- this.#activeSubscription = null;
729
- await this.#enqueue(() => native.sessionSubscribeStop(subscriptionHandle));
730
- };
731
- // Memoized so it is safe to call more than once, from more than one
732
- // place -- the caller's own returned stop(), AND onClosed's own
733
- // internal call below when the reader exits on its own -- without
734
- // either double-invoking the native stop (which would either be a
735
- // wasted call or, worse, race a second subscribe() that had since
736
- // reused this Session). Whichever caller gets here first actually
737
- // runs realStop(); everyone else gets that same settled outcome.
738
- const stop = () => {
739
- if (stopPromise === null)
740
- stopPromise = realStop();
741
- return stopPromise;
742
- };
743
- const onClosed = (error) => {
744
- console.error(`macula-ts: session.subscribe("${topic}") ended unexpectedly, tearing it down:`, error);
745
- // This internal call's own rejection (the underlying transport
746
- // error realStop's native call surfaces once more, tearing down
747
- // an already-dead subscription) is not new information -- error
748
- // is already being reported via onClosed itself, right below.
749
- // A caller's OWN explicit call to the returned stop() afterward
750
- // still resolves/rejects for real, from the same memoized promise.
751
- stop().catch(() => { });
752
- opts.onClosed?.(error);
753
- };
754
- try {
755
- subscriptionHandle = await this.#enqueue(() => native.sessionSubscribeStart(handle, identityHandle, realm, topic, (msg) => {
756
- if (msg.kind === "closed") {
757
- onClosed(new Error(msg.error ?? "subscription closed"));
758
- return;
759
- }
760
- handler({ payload: JSON.parse(msg.payloadJson), publisher: msg.publisher, seq: msg.seq });
761
- }, bytesMode));
762
- }
763
- catch (err) {
764
- this.#activeSubscription = null;
765
- throw err;
766
- }
767
- this.#activeSubscription = { topic, stop };
768
- return stop;
769
- }
770
- /** Content transfer: stores `data` (macula-go's content.Put, on this
771
- * Session's own fresh dedicated QUIC stream -- Session.
772
- * OpenDedicatedStream on the Go side, NOT the shared control stream
773
- * call()/serve()/the DHT methods/subscribe() all read from), chunking
774
- * automatically above manifest.DefaultChunkSize (256 KiB) and
775
- * returning the hex-encoded mcid it's now addressable by. `name` is
776
- * used ONLY on the chunked path (attached to the resulting manifest)
777
- * -- a single-block put ignores it entirely, matching content.Put's
778
- * own documented behavior; leave it unset for small blobs.
779
- *
780
- * NOT durable object storage -- see content.ts's own module doc: a
781
- * station may forget this content later, and there is no list/delete
782
- * operation. Treat this as "hand these bytes to a peer once."
783
- *
784
- * Because Put opens its own dedicated stream instead of reading the
785
- * shared control stream, this is, unlike call()/serve()/the DHT
786
- * methods/subscribe(), never subject to Session's same-Session
787
- * exclusivity guard (#requireHandleNotServing) -- it can run
788
- * concurrently with an active serve()/subscribe() (or another
789
- * putContent()/getContent()) on the same Session without racing.
790
- *
791
- * Real network I/O (one or more signed CALLs on the new stream) --
792
- * runs off the main thread on the native side, like every other
793
- * network-touching method here. */
794
- async putContent(data, name = "") {
795
- const handle = this.#requireHandle();
796
- const mcid = await native.contentPut(handle, this.#identity.handleForFfi(), data, name);
797
- return { mcid };
798
- }
799
- /** Content transfer: fetches and verifies (macula-go's content.Get,
800
- * on its own fresh dedicated QUIC stream, same reasoning as
801
- * putContent() -- including content.Get's own client-side hash
802
- * re-check against `mcid`: a station may only be relaying content it
803
- * doesn't itself store, so its answer is never trusted blindly) the
804
- * content addressed by `mcid` (the hex string putContent() returned).
805
- *
806
- * Rejects with ContentNotFoundError (content.ts) specifically when
807
- * the station reports it doesn't know this mcid -- an expected,
808
- * routine outcome for a one-time transfer mechanism with no
809
- * durability guarantee, not a transport failure; every other failure
810
- * (a bad session, a malformed mcid, a real transport error) rejects
811
- * with a plain Error instead.
812
- *
813
- * Same dedicated-stream, no-exclusivity-guard reasoning as
814
- * putContent() -- safe alongside an active serve()/subscribe() on the
815
- * same Session. Real network I/O, runs off the main thread. */
816
- async getContent(mcid) {
817
- const handle = this.#requireHandle();
818
- const data = await native.contentGet(handle, this.#identity.handleForFfi(), mcid);
819
- if (data === null)
820
- throw new ContentNotFoundError(mcid);
821
- return data;
822
- }
823
- }
824
- function requireKey32(key) {
825
- if (key.length !== 32) {
826
- throw new Error(`macula-ts: DHT key must be exactly 32 bytes, got ${key.length}`);
827
- }
828
- }
829
- //# sourceMappingURL=session.js.map