@macula-io/ts 0.17.0 → 0.19.0

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