@net-mesh/browser 0.37.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 (110) hide show
  1. package/CHANGELOG.md +517 -0
  2. package/LICENSE-APACHE +190 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +666 -0
  5. package/dist/async-queue.d.ts +26 -0
  6. package/dist/async-queue.js +62 -0
  7. package/dist/credential.d.ts +70 -0
  8. package/dist/credential.js +95 -0
  9. package/dist/errors.d.ts +400 -0
  10. package/dist/errors.js +726 -0
  11. package/dist/events.d.ts +232 -0
  12. package/dist/events.js +298 -0
  13. package/dist/identity.d.ts +40 -0
  14. package/dist/identity.js +78 -0
  15. package/dist/index.bundle.js +2 -0
  16. package/dist/index.d.ts +38 -0
  17. package/dist/index.js +46 -0
  18. package/dist/leader/events.d.ts +111 -0
  19. package/dist/leader/events.js +116 -0
  20. package/dist/leader/index.d.ts +14 -0
  21. package/dist/leader/index.js +11 -0
  22. package/dist/leader/session.d.ts +539 -0
  23. package/dist/leader/session.js +770 -0
  24. package/dist/leader/wasm.d.ts +123 -0
  25. package/dist/leader/wasm.js +39 -0
  26. package/dist/lobby.d.ts +183 -0
  27. package/dist/lobby.js +504 -0
  28. package/dist/local.d.ts +80 -0
  29. package/dist/local.js +160 -0
  30. package/dist/net_leaf.d.ts +1494 -0
  31. package/dist/net_leaf.js +3753 -0
  32. package/dist/net_leaf_bg.wasm +0 -0
  33. package/dist/net_leaf_bg.wasm.d.ts +137 -0
  34. package/dist/netcode/client.d.ts +102 -0
  35. package/dist/netcode/client.js +296 -0
  36. package/dist/netcode/clock.d.ts +45 -0
  37. package/dist/netcode/clock.js +54 -0
  38. package/dist/netcode/host.d.ts +118 -0
  39. package/dist/netcode/host.js +276 -0
  40. package/dist/netcode/index.d.ts +19 -0
  41. package/dist/netcode/index.js +14 -0
  42. package/dist/netcode/interpolate.d.ts +50 -0
  43. package/dist/netcode/interpolate.js +100 -0
  44. package/dist/netcode/wire.d.ts +125 -0
  45. package/dist/netcode/wire.js +117 -0
  46. package/dist/node.d.ts +908 -0
  47. package/dist/node.js +1085 -0
  48. package/dist/org.d.ts +412 -0
  49. package/dist/org.js +567 -0
  50. package/dist/peer-driver.d.ts +117 -0
  51. package/dist/peer-driver.js +224 -0
  52. package/dist/store/assembly.d.ts +176 -0
  53. package/dist/store/assembly.js +325 -0
  54. package/dist/store/chunker.d.ts +83 -0
  55. package/dist/store/chunker.js +190 -0
  56. package/dist/store/core.d.ts +136 -0
  57. package/dist/store/core.js +477 -0
  58. package/dist/store/definition.d.ts +23 -0
  59. package/dist/store/definition.js +83 -0
  60. package/dist/store/errors.d.ts +77 -0
  61. package/dist/store/errors.js +94 -0
  62. package/dist/store/host.d.ts +247 -0
  63. package/dist/store/host.js +675 -0
  64. package/dist/store/index.d.ts +50 -0
  65. package/dist/store/index.js +40 -0
  66. package/dist/store/interest.d.ts +40 -0
  67. package/dist/store/interest.js +77 -0
  68. package/dist/store/inventory.d.ts +81 -0
  69. package/dist/store/inventory.js +140 -0
  70. package/dist/store/join.d.ts +118 -0
  71. package/dist/store/join.js +724 -0
  72. package/dist/store/json.d.ts +50 -0
  73. package/dist/store/json.js +283 -0
  74. package/dist/store/ledger.d.ts +187 -0
  75. package/dist/store/ledger.js +292 -0
  76. package/dist/store/owner.d.ts +616 -0
  77. package/dist/store/owner.js +1711 -0
  78. package/dist/store/patch.d.ts +70 -0
  79. package/dist/store/patch.js +166 -0
  80. package/dist/store/player.d.ts +65 -0
  81. package/dist/store/player.js +261 -0
  82. package/dist/store/replica.d.ts +187 -0
  83. package/dist/store/replica.js +740 -0
  84. package/dist/store/state.d.ts +42 -0
  85. package/dist/store/state.js +165 -0
  86. package/dist/store/types.d.ts +200 -0
  87. package/dist/store/types.js +25 -0
  88. package/dist/store/visibility.d.ts +118 -0
  89. package/dist/store/visibility.js +282 -0
  90. package/dist/store/wire.d.ts +309 -0
  91. package/dist/store/wire.js +834 -0
  92. package/dist/stream.d.ts +174 -0
  93. package/dist/stream.js +238 -0
  94. package/dist/three/index.d.ts +109 -0
  95. package/dist/three/index.js +130 -0
  96. package/dist/udp-probe.d.ts +159 -0
  97. package/dist/udp-probe.js +245 -0
  98. package/dist/wasm.d.ts +642 -0
  99. package/dist/wasm.js +98 -0
  100. package/dist/world/border.d.ts +84 -0
  101. package/dist/world/border.js +99 -0
  102. package/dist/world/handoff.d.ts +187 -0
  103. package/dist/world/handoff.js +222 -0
  104. package/dist/world/index.d.ts +13 -0
  105. package/dist/world/index.js +9 -0
  106. package/dist/world/link.d.ts +179 -0
  107. package/dist/world/link.js +476 -0
  108. package/dist/world/regions.d.ts +154 -0
  109. package/dist/world/regions.js +314 -0
  110. package/package.json +72 -0
package/dist/node.js ADDED
@@ -0,0 +1,1085 @@
1
+ /**
2
+ * `BrowserNode` — the surface a page actually uses.
3
+ *
4
+ * A thin, typed, camelCased layer over the wasm node: it owns the one
5
+ * `on_event` registration, re-types everything the boundary throws,
6
+ * parses `query`'s JSON without rounding 64-bit ids, and applies the
7
+ * Stage 5 failure-typing correction to an ICE failure when — and only
8
+ * when — the distinguishing evidence can be obtained.
9
+ */
10
+ import { EventHub, parseEvent, parseJsonPreservingU64, toBase64, } from './events.js';
11
+ import { fromWasmError, IdentityError, OrgCancelledError, RtcError } from './errors.js';
12
+ import { clientStreamOrgTrampoline, duplexOrgTrampoline, OrgDuplex, OrgHandleRegistry, OrgServe, OrgStream, OrgUpload, streamingOrgTrampoline, toWasmOrgCallOptions, toWasmOrgServeOptions, unaryOrgTrampoline, } from './org.js';
13
+ import { LeafStream } from './stream.js';
14
+ import { acceptPeer as driveAccept, connectPeer as driveConnect, handshakePeer as driveHandshake, } from './peer-driver.js';
15
+ import { classifyRtcError, probeBootstrapReachable, probeStunBinding, } from './udp-probe.js';
16
+ import { idArg, loadLeafWasm, } from './wasm.js';
17
+ /**
18
+ * Convert a decimal `u64` node id — {@link NodeDescriptor.nodeId},
19
+ * or any other decimal mesh id — into the 16-lowercase-hex-digit
20
+ * spelling the peer methods require.
21
+ *
22
+ * Explicit, because the alternative is worse in both directions. The
23
+ * leaf refuses a decimal peer id deliberately (one contract: `0009`
24
+ * must not name two different nodes depending on which method read
25
+ * it), and a wrapper that guessed which spelling it had been handed
26
+ * would dial whichever node its guess produced. So the caller says
27
+ * which conversion it means.
28
+ *
29
+ * `BigInt` rather than `Number`: a mesh id is a full `u64` and
30
+ * `parseInt` would round it.
31
+ *
32
+ * @throws TypeError if `nodeId` is not a decimal integer inside
33
+ * `u64`.
34
+ */
35
+ export function peerIdHex(nodeId) {
36
+ if (!/^\d+$/.test(nodeId)) {
37
+ throw new TypeError(`a decimal node id is digits only, got ${JSON.stringify(nodeId)}`);
38
+ }
39
+ const value = BigInt(nodeId);
40
+ if (value > 0xffffffffffffffffn) {
41
+ throw new TypeError(`${nodeId} does not fit in a u64 node id`);
42
+ }
43
+ return value.toString(16).padStart(16, '0');
44
+ }
45
+ /** A browser-native Net node. */
46
+ export class BrowserNode {
47
+ inner;
48
+ bootstrapUrl;
49
+ failureTyping;
50
+ hub = new EventHub();
51
+ nodeId;
52
+ closed = false;
53
+ /**
54
+ * Streams this node handed out and that are still open, so closing
55
+ * the node can end them.
56
+ *
57
+ * The direct surface's half of the same guarantee `MeshSession`
58
+ * gives across a leader change: after `close()` the wasm node
59
+ * delivers nothing and no stream will ever emit again, so a
60
+ * consumer sitting in `await iterator.next()` would never settle.
61
+ * Nothing else can end it — the queue is fed only by the stream's
62
+ * `on_message` callback, and a closed node does not call it. So
63
+ * the node owns the set and drains it, which turns "no more data,
64
+ * ever" into the end of the iteration a page is already written to
65
+ * handle.
66
+ */
67
+ streams = new Set();
68
+ /**
69
+ * The live org handles (plan §4.5), so {@link BrowserNode.close}
70
+ * can retire ownership: pending calls fail typed and each dropped
71
+ * caller handle emits exactly one CANCEL.
72
+ */
73
+ orgHandles = new OrgHandleRegistry();
74
+ /** The last `rtc_addr` an anchor told us about; the probe's subject. */
75
+ anchorRtcAddr;
76
+ /** Set once the leaf reports a session — a bootstrap that demonstrably worked. */
77
+ bootstrapObserved = false;
78
+ /** @internal — use {@link connect}. */
79
+ constructor(inner, bootstrapUrl, failureTyping, anchorRtcAddr) {
80
+ this.inner = inner;
81
+ this.bootstrapUrl = bootstrapUrl;
82
+ this.failureTyping = failureTyping;
83
+ this.nodeId = inner.node_id_hex();
84
+ this.anchorRtcAddr = anchorRtcAddr;
85
+ inner.on_event((json) => this.ingest(json));
86
+ }
87
+ /** This node's mesh id, hex. */
88
+ nodeIdHex() {
89
+ return this.nodeId;
90
+ }
91
+ /** The anchor's mesh id, hex. */
92
+ anchorIdHex() {
93
+ return this.inner.anchor_id_hex();
94
+ }
95
+ /**
96
+ * This node's origin hash, hex.
97
+ *
98
+ * Distinct from {@link BrowserNode.nodeIdHex} and not a synonym:
99
+ * the origin hash rides every packet header this node seals, names
100
+ * its nRPC reply channels (`<service>.replies.<origin>`), and is
101
+ * what a receiver compares an event payload's
102
+ * `EventMeta.origin_hash` against — a direct peer whose header
103
+ * origin and payload origin disagree has the frame dropped before
104
+ * admission. A page that hands this node a pre-encoded event
105
+ * payload to send must build it with THIS value.
106
+ */
107
+ originHashHex() {
108
+ return this.inner.origin_hash_hex();
109
+ }
110
+ /**
111
+ * Run the enrollment exchange.
112
+ *
113
+ * {@link connect} already awaits it, so a page needs this only to
114
+ * drive or observe the step — a harness proving that an unenrolled
115
+ * leaf is exactly what a provisional peer looks like, for instance.
116
+ * Until a leaf is enrolled the anchor keeps its peer provisional and
117
+ * §12 refuses everything above the transport, which surfaces here as
118
+ * `rpc-timeout` on calls rather than as a transport failure.
119
+ */
120
+ async enroll() {
121
+ try {
122
+ await this.inner.enroll();
123
+ }
124
+ catch (error) {
125
+ throw fromWasmError(error);
126
+ }
127
+ }
128
+ /**
129
+ * Whether the anchor has admitted this leaf. `false` means the
130
+ * session is still §12-provisional, so a call will die on its
131
+ * deadline — check this before blaming a `rpc-timeout` on a slow
132
+ * anchor.
133
+ */
134
+ isEnrolled() {
135
+ return this.inner.is_enrolled();
136
+ }
137
+ /**
138
+ * Call an nRPC service and resolve its reply.
139
+ *
140
+ * Rejects with {@link RpcError} (`rpc-refused`, `rpc-timeout`,
141
+ * `session-lost`, `leader-lost`, `rpc-malformed`) — never with a
142
+ * silent retry, which is §8's rule for a call whose leader or
143
+ * session went away.
144
+ */
145
+ async call(service, payload, timeoutMs) {
146
+ try {
147
+ return await this.inner.call(service, payload, timeoutMs);
148
+ }
149
+ catch (error) {
150
+ throw fromWasmError(error);
151
+ }
152
+ }
153
+ /**
154
+ * Subscribe to a channel. Messages arrive as `channel_message`
155
+ * events; the leaf only delivers channels this node subscribed to,
156
+ * and `channelHash` is its hash of the name.
157
+ */
158
+ async subscribe(channel) {
159
+ try {
160
+ await this.inner.subscribe(channel);
161
+ }
162
+ catch (error) {
163
+ throw fromWasmError(error);
164
+ }
165
+ }
166
+ /** Publish one payload to a channel. */
167
+ async publish(channel, payload) {
168
+ try {
169
+ await this.inner.publish(channel, payload);
170
+ }
171
+ catch (error) {
172
+ throw fromWasmError(error);
173
+ }
174
+ }
175
+ /**
176
+ * Open a reliable or fire-and-forget stream.
177
+ *
178
+ * The stream is retained until it or the node closes, so
179
+ * {@link BrowserNode.close} can end the consumers it handed out.
180
+ *
181
+ * On a **closed** node this is a typed refusal, not a dead handle:
182
+ * the leaf's own fence (`Inner::admit`) rejects every outbound
183
+ * operation on a node that no longer holds the origin's identity,
184
+ * and that is re-typed here as `SessionError` (`kind: 'session'`)
185
+ * like every other boundary failure. There is deliberately no
186
+ * second check in TypeScript — one fence, on the side that owns
187
+ * the node's lifetime.
188
+ */
189
+ openStream(options) {
190
+ let inner;
191
+ try {
192
+ inner = this.inner.open_stream(options);
193
+ }
194
+ catch (error) {
195
+ throw fromWasmError(error);
196
+ }
197
+ const stream = new LeafStream(inner, () => this.streams.delete(stream));
198
+ this.streams.add(stream);
199
+ return stream;
200
+ }
201
+ // ── Organization-scoped streaming (plan §4.5) ────────────────────────
202
+ //
203
+ // Eight verbs, identical on `MeshSession`. Terminal outcomes are the
204
+ // §4.3 vocabulary (`errors.ts`'s `OrgStreamError` family).
205
+ /**
206
+ * Call an org-protected unary service.
207
+ *
208
+ * Rejects with the §4.3 typed error: an **opening denial** is
209
+ * {@link OrgAdmissionDeniedError} carrying the coarse reason and
210
+ * nothing finer — a precise remote reason would be a credential
211
+ * oracle. Never a silent retry: the caller is told and decides.
212
+ *
213
+ * **Suspension and closure (plan §4.5).** Deadlines are absolute: a
214
+ * frozen tab extends no lease and no window, so a call whose
215
+ * deadline passes while its tab is suspended retires with its
216
+ * deadline terminal on wake — {@link OrgTimeoutError}, or
217
+ * {@link OrgIndeterminateError} where the work may already be
218
+ * executing elsewhere. Nothing resumes automatically: a retired
219
+ * call is never transparently re-opened, because re-opening is a
220
+ * fresh call with a fresh proof and MAY repeat effects. Node or tab
221
+ * close retires ownership — pending calls fail typed, and each
222
+ * dropped caller handle emits exactly one CANCEL.
223
+ */
224
+ async callOrg(service, payload, options) {
225
+ try {
226
+ return await this.inner.call_org(service, payload, toWasmOrgCallOptions(options));
227
+ }
228
+ catch (error) {
229
+ throw fromWasmError(error);
230
+ }
231
+ }
232
+ /**
233
+ * Call an org-protected server-streaming service; resolve to the
234
+ * response stream.
235
+ *
236
+ * An **opening denial** rejects here with
237
+ * {@link OrgAdmissionDeniedError} (coarse) — a denial is a
238
+ * rejection, never a terminal item. Midstream, the stream's FINAL
239
+ * error item carries the §4.3 outcome: revocation is
240
+ * `AdmissionDenied('denied')` ({@link OrgRevokedError} where the
241
+ * cause is known), deadline/cancel retirement is
242
+ * {@link OrgTimeoutError}/{@link OrgCancelledError}. The async
243
+ * iterator throws that typed error at the terminal error item.
244
+ *
245
+ * **Suspension and closure (plan §4.5).** Deadlines are absolute: a
246
+ * frozen tab extends no lease and no window, so a call whose
247
+ * deadline passes while its tab is suspended retires with its
248
+ * deadline terminal on wake — {@link OrgTimeoutError}, or
249
+ * {@link OrgIndeterminateError} where the work may already be
250
+ * executing elsewhere. Nothing resumes automatically: a retired
251
+ * call is never transparently re-opened, because re-opening is a
252
+ * fresh call with a fresh proof and MAY repeat effects. Node or tab
253
+ * close retires ownership — pending calls fail typed, and each
254
+ * dropped caller handle emits exactly one CANCEL.
255
+ */
256
+ async callOrgStreaming(service, payload, options) {
257
+ try {
258
+ const handle = await this.inner.call_org_streaming(service, payload, toWasmOrgCallOptions(options));
259
+ const stream = new OrgStream(handle, () => handle.cancel(), () => this.orgHandles.delete(stream));
260
+ this.orgHandles.add(stream);
261
+ return stream;
262
+ }
263
+ catch (error) {
264
+ throw fromWasmError(error);
265
+ }
266
+ }
267
+ /**
268
+ * Open an org-protected client-streaming upload. `finish()` is the
269
+ * half-close and the reply await, and it rejects with the typed
270
+ * terminal error (§4.3).
271
+ *
272
+ * An **opening denial** rejects here with
273
+ * {@link OrgAdmissionDeniedError} (coarse).
274
+ *
275
+ * **Suspension and closure (plan §4.5).** Deadlines are absolute: a
276
+ * frozen tab extends no lease and no window, so a call whose
277
+ * deadline passes while its tab is suspended retires with its
278
+ * deadline terminal on wake — {@link OrgTimeoutError}, or
279
+ * {@link OrgIndeterminateError} where the work may already be
280
+ * executing elsewhere. Nothing resumes automatically: a retired
281
+ * call is never transparently re-opened, because re-opening is a
282
+ * fresh call with a fresh proof and MAY repeat effects. Node or tab
283
+ * close retires ownership — pending calls fail typed, and each
284
+ * dropped caller handle emits exactly one CANCEL.
285
+ */
286
+ async callOrgClientStream(service, options) {
287
+ try {
288
+ const handle = await this.inner.call_org_client_stream(service, toWasmOrgCallOptions(options));
289
+ const upload = new OrgUpload(handle, () => this.orgHandles.delete(upload));
290
+ this.orgHandles.add(upload);
291
+ return upload;
292
+ }
293
+ catch (error) {
294
+ throw fromWasmError(error);
295
+ }
296
+ }
297
+ /**
298
+ * Open an org-protected duplex call: an upload sink and an
299
+ * independent response stream.
300
+ *
301
+ * An **opening denial** rejects here with
302
+ * {@link OrgAdmissionDeniedError} (coarse). Midstream outcomes are
303
+ * the §4.3 terminals on each half.
304
+ *
305
+ * **Suspension and closure (plan §4.5).** Deadlines are absolute: a
306
+ * frozen tab extends no lease and no window, so a call whose
307
+ * deadline passes while its tab is suspended retires with its
308
+ * deadline terminal on wake — {@link OrgTimeoutError}, or
309
+ * {@link OrgIndeterminateError} where the work may already be
310
+ * executing elsewhere. Nothing resumes automatically: a retired
311
+ * call is never transparently re-opened, because re-opening is a
312
+ * fresh call with a fresh proof and MAY repeat effects. Node or tab
313
+ * close retires ownership — pending calls fail typed, and each
314
+ * dropped caller handle emits exactly one CANCEL.
315
+ */
316
+ async callOrgDuplex(service, options) {
317
+ try {
318
+ const handle = await this.inner.call_org_duplex(service, toWasmOrgCallOptions(options));
319
+ const call = new OrgDuplex(handle, () => this.orgHandles.delete(call));
320
+ this.orgHandles.add(call);
321
+ return call;
322
+ }
323
+ catch (error) {
324
+ throw fromWasmError(error);
325
+ }
326
+ }
327
+ /**
328
+ * Serve an org-protected unary service. `access` is `'same-org'`
329
+ * (owner-org traffic only) or `'granted'` (capability grants too);
330
+ * anything else is refused loudly at registration.
331
+ *
332
+ * **Handler drop (F-S3.1-2).** The retire supervisor may drop the
333
+ * handler future without a final poll — cancellation is observed
334
+ * through the retirement observables (the terminal item, the sink's
335
+ * typed closed refusal, and `retired`), never assumed as a
336
+ * handler-side event; a detached observer holding `retired`
337
+ * observes the signal. After retirement a handler sees typed
338
+ * refusals and any return value it produces is discarded.
339
+ *
340
+ * **`close()` is a retirement, not a graceful unregister (C9's
341
+ * protected split).** Closing the returned handle retires its live
342
+ * protected calls — each with its exact retirement terminal, the
343
+ * deadline/cancel/revocation vocabulary — and refuses new openings
344
+ * on the service. Deliberately NOT the public path's
345
+ * let-existing-calls-finish behavior.
346
+ */
347
+ serveOrg(service, access, handler, options) {
348
+ try {
349
+ const serve = new OrgServe(this.inner.serve_org(service, access, unaryOrgTrampoline(handler), toWasmOrgServeOptions(options)), () => this.orgHandles.delete(serve));
350
+ this.orgHandles.add(serve);
351
+ return serve;
352
+ }
353
+ catch (error) {
354
+ throw fromWasmError(error);
355
+ }
356
+ }
357
+ /**
358
+ * Serve an org-protected server-streaming service: one request in,
359
+ * items out through the {@link OrgResponseSink}.
360
+ *
361
+ * **Handler drop (F-S3.1-2).** The retire supervisor may drop the
362
+ * handler future without a final poll — cancellation is observed
363
+ * through the retirement observables (the terminal item, the sink's
364
+ * typed closed refusal, and `retired`), never assumed as a
365
+ * handler-side event; a detached observer holding `retired`
366
+ * observes the signal. After retirement a handler sees typed
367
+ * refusals and any return value it produces is discarded.
368
+ *
369
+ * **`close()` is a retirement, not a graceful unregister (C9's
370
+ * protected split).** Closing the returned handle retires its live
371
+ * protected calls — each with its exact retirement terminal, the
372
+ * deadline/cancel/revocation vocabulary — and refuses new openings
373
+ * on the service. Deliberately NOT the public path's
374
+ * let-existing-calls-finish behavior.
375
+ */
376
+ serveOrgStreaming(service, access, handler, options) {
377
+ try {
378
+ const serve = new OrgServe(this.inner.serve_org_streaming(service, access, streamingOrgTrampoline(handler), toWasmOrgServeOptions(options)), () => this.orgHandles.delete(serve));
379
+ this.orgHandles.add(serve);
380
+ return serve;
381
+ }
382
+ catch (error) {
383
+ throw fromWasmError(error);
384
+ }
385
+ }
386
+ /**
387
+ * Serve an org-protected client-streaming service: requests in
388
+ * through the {@link OrgRequestStream}, one reply out.
389
+ *
390
+ * **Handler drop (F-S3.1-2).** The retire supervisor may drop the
391
+ * handler future without a final poll — cancellation is observed
392
+ * through the retirement observables (the terminal item, the sink's
393
+ * typed closed refusal, and `retired`), never assumed as a
394
+ * handler-side event; a detached observer holding `retired`
395
+ * observes the signal. After retirement a handler sees typed
396
+ * refusals and any return value it produces is discarded.
397
+ *
398
+ * **`close()` is a retirement, not a graceful unregister (C9's
399
+ * protected split).** Closing the returned handle retires its live
400
+ * protected calls — each with its exact retirement terminal, the
401
+ * deadline/cancel/revocation vocabulary — and refuses new openings
402
+ * on the service. Deliberately NOT the public path's
403
+ * let-existing-calls-finish behavior.
404
+ */
405
+ serveOrgClientStream(service, access, handler, options) {
406
+ try {
407
+ const serve = new OrgServe(this.inner.serve_org_client_stream(service, access, clientStreamOrgTrampoline(handler), toWasmOrgServeOptions(options)), () => this.orgHandles.delete(serve));
408
+ this.orgHandles.add(serve);
409
+ return serve;
410
+ }
411
+ catch (error) {
412
+ throw fromWasmError(error);
413
+ }
414
+ }
415
+ /**
416
+ * Serve an org-protected duplex service: requests in and items out,
417
+ * independently, through the request stream and the sink.
418
+ *
419
+ * **Handler drop (F-S3.1-2).** The retire supervisor may drop the
420
+ * handler future without a final poll — cancellation is observed
421
+ * through the retirement observables (the terminal item, the sink's
422
+ * typed closed refusal, and `retired`), never assumed as a
423
+ * handler-side event; a detached observer holding `retired`
424
+ * observes the signal. After retirement a handler sees typed
425
+ * refusals and any return value it produces is discarded.
426
+ *
427
+ * **`close()` is a retirement, not a graceful unregister (C9's
428
+ * protected split).** Closing the returned handle retires its live
429
+ * protected calls — each with its exact retirement terminal, the
430
+ * deadline/cancel/revocation vocabulary — and refuses new openings
431
+ * on the service. Deliberately NOT the public path's
432
+ * let-existing-calls-finish behavior.
433
+ */
434
+ serveOrgDuplex(service, access, handler, options) {
435
+ try {
436
+ const serve = new OrgServe(this.inner.serve_org_duplex(service, access, duplexOrgTrampoline(handler), toWasmOrgServeOptions(options)), () => this.orgHandles.delete(serve));
437
+ this.orgHandles.add(serve);
438
+ return serve;
439
+ }
440
+ catch (error) {
441
+ throw fromWasmError(error);
442
+ }
443
+ }
444
+ /**
445
+ * Sign and send a `0x0D02` signalling envelope to `peer` — the
446
+ * session-independent path, so no session with `peer` is needed.
447
+ *
448
+ * `dialogHex` is the attempt's id, 16 lowercase hex digits — the
449
+ * same value {@link BrowserNode.offerPeer} resolves to. Never a
450
+ * number: a `u64` dialog through a JS number comes back rounded,
451
+ * and an envelope signed for a dialog that names no attempt kills
452
+ * the attempt at its ICE deadline.
453
+ */
454
+ async signal(peerHex, dialogHex, kind, payload) {
455
+ try {
456
+ // Both ids cross as strings, verbatim, and a non-string is
457
+ // refused "is not a peer id" / "is not a dialog id" — peer
458
+ // first, the order `LeafNode::signal` parses in — before the
459
+ // wasm seam, whose String marshaling corrupts on a number
460
+ // (finding #52).
461
+ await this.inner.signal(idArg(peerHex, 'peer'), idArg(dialogHex, 'dialog'), kind, payload);
462
+ }
463
+ catch (error) {
464
+ throw fromWasmError(error);
465
+ }
466
+ }
467
+ /**
468
+ * Connect **directly** to another browser node — plan §9, from a
469
+ * page.
470
+ *
471
+ * The whole sequence, driven here so a page does not have to:
472
+ * bring up the relayed session through the anchor if there is not
473
+ * one, offer, trickle candidates both ways as signed envelopes,
474
+ * and run the Noise handshake over the DataChannel that results.
475
+ * The direct session then **replaces** the relayed one rather than
476
+ * joining it (§9 step 4), through the node's own session-table
477
+ * fence.
478
+ *
479
+ * `nodeIdHex` is the peer's mesh id in the spelling every peer
480
+ * method takes: 16 lowercase HEX digits —
481
+ * {@link BrowserNode.nodeIdHex} on the other tab, or
482
+ * {@link NodeDescriptor.peerIdHex} from a
483
+ * {@link BrowserNode.query} descriptor. It is **not**
484
+ * {@link NodeDescriptor.nodeId}, which is that id's decimal
485
+ * spelling; pass {@link peerIdHex} over it, or the decimal digits
486
+ * name a different node.
487
+ *
488
+ * **Nothing else is passed, and nothing else can be.** No SDP, no
489
+ * candidate, no key: the peer's Noise static comes from its
490
+ * signature-verified announcement and the pre-shared key from the
491
+ * credential this node connected with. A page that could supply a
492
+ * key could supply any key.
493
+ *
494
+ * **Idempotent on a healthy pair.** When the pair is already direct
495
+ * and its channel open, this resolves `direct` with the live dialog
496
+ * and sends nothing: a second offer would replace the working link
497
+ * and close it under the other side. So a library may call it "to be
498
+ * sure" before opening a stream.
499
+ *
500
+ * Returns a {@link PeerConnectOutcome}. It **rejects** only for
501
+ * something that is not a disposition of the attempt — a closed
502
+ * node, a peer that answered `Reject`, a malformed peer id.
503
+ */
504
+ async connectPeer(nodeIdHex) {
505
+ // Already direct and open: the pair it has. A new offer would
506
+ // replace the working link and close it under the other side.
507
+ let settled;
508
+ try {
509
+ settled = this.inner.peer_direct_dialog(idArg(nodeIdHex, 'peer'));
510
+ }
511
+ catch (error) {
512
+ throw fromWasmError(error);
513
+ }
514
+ if (settled !== undefined && settled !== null)
515
+ return { type: 'direct', peer: nodeIdHex, dialog: settled };
516
+ return driveConnect(nodeIdHex, this.peerPrimitives(), parseAttemptStatus);
517
+ }
518
+ /**
519
+ * The four primitives the shared drive loop calls, bound to this
520
+ * tab's own node.
521
+ *
522
+ * The dialog-named `*_in` forms, not the peer-only ones: one
523
+ * primitive set for both surfaces means one classification, and a
524
+ * direct caller loses nothing by naming the dialog it already
525
+ * holds.
526
+ */
527
+ peerPrimitives() {
528
+ return {
529
+ offer: (peer) => this.inner.peer_offer(peer),
530
+ acceptOffer: (peer) => this.inner.peer_accept_offer(peer),
531
+ candidate: (peer, dialog) => this.inner.peer_candidate_in(peer, dialog),
532
+ handshake: (peer, dialog) => this.inner.peer_handshake_in(peer, dialog),
533
+ };
534
+ }
535
+ /**
536
+ * Run the offerer's Noise handshake on an attempt whose
537
+ * DataChannel is already open, and type the result the way
538
+ * {@link BrowserNode.connectPeer} types it.
539
+ *
540
+ * `connectPeer` is the drive loop plus exactly this call, so there
541
+ * is one classifier and not two. It is public because the four
542
+ * `#[wasm_bindgen]` methods are a page surface in their own right:
543
+ * a page that drove `peer_offer`, `peer_accept_offer` and
544
+ * `peer_candidate` itself — because it wanted to observe or
545
+ * intervene between them — would otherwise have to re-derive the
546
+ * typing of the last step, and a second classifier is a second
547
+ * set of bugs.
548
+ *
549
+ * `dialog` is the id `peer_offer` returned, and it is CHECKED
550
+ * rather than assumed: `peer_handshake` resolves to the dialog it
551
+ * actually ran for, and any mismatch is this caller's attempt
552
+ * having been replaced underneath it. Reporting `direct` with the
553
+ * caller's own dialog on any success is how a stale caller was
554
+ * told its superseded attempt had connected — while the leaf had
555
+ * handshaken the attempt that replaced it.
556
+ */
557
+ async handshakePeer(nodeIdHex, dialog) {
558
+ return driveHandshake(nodeIdHex, dialog, this.peerPrimitives());
559
+ }
560
+ /**
561
+ * Accept the offer `nodeIdHex` sent, and drive the attempt to a
562
+ * direct session — {@link BrowserNode.connectPeer}'s counterpart.
563
+ *
564
+ * Call it when a `signal` event arrives from that peer: the offer
565
+ * it answers is the **verified envelope** the leaf already holds,
566
+ * so the page neither sees nor supplies the SDP.
567
+ *
568
+ * The answerer does not run the handshake — §9 step 4 puts Noise
569
+ * in the offerer's role, so this waits for the direct session to
570
+ * install from the inbound message instead. Same
571
+ * {@link PeerConnectOutcome}, same meanings.
572
+ */
573
+ async acceptPeer(nodeIdHex) {
574
+ return driveAccept(nodeIdHex, this.peerPrimitives(), parseAttemptStatus);
575
+ }
576
+ /**
577
+ * Read one `peer_candidate` status for a live attempt.
578
+ *
579
+ * Exposed because it is the only way to observe an attempt without
580
+ * driving it to a conclusion — which is what a witness asserting
581
+ * the shape of the exchange needs, and what a page showing
582
+ * connection progress wants.
583
+ */
584
+ async peerAttempt(nodeIdHex) {
585
+ try {
586
+ return parseAttemptStatus(await this.inner.peer_candidate(nodeIdHex));
587
+ }
588
+ catch (error) {
589
+ throw fromWasmError(error);
590
+ }
591
+ }
592
+ /**
593
+ * Every leaf counter, including each drop reason. Values are exact
594
+ * decimal strings because they are `u64` on the Rust side.
595
+ */
596
+ counters() {
597
+ return parseCounters(this.inner.counters_json());
598
+ }
599
+ /**
600
+ * The RTC transport's `RtcStats`, in the **same field names** the
601
+ * native `RtcStats` uses — plan §10's telemetry surface on the
602
+ * leaf.
603
+ *
604
+ * Values are exact decimal strings, for the reason
605
+ * {@link BrowserNode.counters} gives. `notApplicable` is not a
606
+ * counter: it maps each native field a leaf has no meaning for to
607
+ * the reason it has none, because a field frozen at `0` reads as
608
+ * an observation ("no ingress overflow", "no STUN requests
609
+ * answered") and a browser leaf is not entitled to those claims.
610
+ * Native makes the same choice in the other direction — it
611
+ * carries no `udpBlocked`/`udp_blocked` field at all — so
612
+ * `udp_blocked` is the one term here with no native counterpart.
613
+ *
614
+ * `ice_pending` is absent on both sides on purpose: it is
615
+ * `ice_attempted - (direct + relayed + failed + udp_blocked)`,
616
+ * the attempts still in flight, and the §10 identity is exact
617
+ * only where that residual is zero. Derive it; do not expect it.
618
+ */
619
+ rtcStats() {
620
+ return parseRtcStats(this.inner.rtc_stats_json());
621
+ }
622
+ /**
623
+ * Arm the network-change re-attempt trigger: `online` events and
624
+ * an ICE `disconnected` → `failed` transition drive **one**
625
+ * bounded re-attempt per network change, per peer.
626
+ *
627
+ * Opt-in, and that is a decision rather than an omission.
628
+ * Re-dialling is policy and policy is the application's: a page
629
+ * may be holding a pair on the routed path deliberately, may be
630
+ * tearing down, may be on a metered link. §9 step 6 makes the
631
+ * routed session a supported disposition rather than a degraded
632
+ * one, so "this pair is relayed" is not a fault the library may
633
+ * assume it should fix.
634
+ *
635
+ * Idempotent — a second call installs no second listener. Only
636
+ * pairs this node took direct **as the offerer** are
637
+ * re-attempted: `connectPeer`'s side owns the repair, and both
638
+ * ends re-offering one network change would be two attempts per
639
+ * pair plus a supersession race.
640
+ *
641
+ * Once armed, {@link BrowserNode.retryReport} is how a page
642
+ * observes it.
643
+ */
644
+ enableNetworkRetry() {
645
+ try {
646
+ this.inner.arm_network_retry();
647
+ }
648
+ catch (error) {
649
+ throw fromWasmError(error);
650
+ }
651
+ }
652
+ /**
653
+ * What the re-attempt owner has seen and done.
654
+ *
655
+ * `started` is the assertion that matters: one network change, one
656
+ * re-attempt, however many observations of it the browser
657
+ * delivered. `triggers` is how many arrived and `coalesced` is
658
+ * the difference the owner absorbed — reported rather than
659
+ * hidden, because "the trigger never fired" and "it fired and was
660
+ * absorbed" are different facts and only one of them is a defect.
661
+ */
662
+ retryReport() {
663
+ return parseRetryReport(this.inner.retry_report());
664
+ }
665
+ /** Publish this node's capabilities as a signed fold announcement. */
666
+ async announce(capabilities) {
667
+ try {
668
+ await this.inner.announce([...capabilities]);
669
+ }
670
+ catch (error) {
671
+ throw fromWasmError(error);
672
+ }
673
+ }
674
+ /** Find the nodes offering a capability. */
675
+ async query(capability) {
676
+ let json;
677
+ try {
678
+ json = await this.inner.query(capability);
679
+ }
680
+ catch (error) {
681
+ throw fromWasmError(error);
682
+ }
683
+ return parseDescriptors(json);
684
+ }
685
+ /** Listen for one event tag. Returns a cancel handle. */
686
+ on(type, handler) {
687
+ return this.hub.on(type, handler);
688
+ }
689
+ /** Listen for every event. Returns a cancel handle. */
690
+ onEvent(handler) {
691
+ return this.hub.onAny(handler);
692
+ }
693
+ /** Consume events as an async iterable. */
694
+ events() {
695
+ return this.hub.events();
696
+ }
697
+ /**
698
+ * Narrow an ICE failure to `udp-blocked` when the evidence is there.
699
+ *
700
+ * Anything that is not an `ice-timeout` is returned untouched. For an
701
+ * `ice-timeout` it gathers the two observations the correction
702
+ * requires — the anchor answering over HTTPS, and a STUN binding to
703
+ * the address that anchor published going unanswered — and returns
704
+ * the classification {@link classifyRtcError} makes of them. With no
705
+ * address to probe there is no evidence, and the error comes back
706
+ * unchanged.
707
+ *
708
+ * Public because a page holding an `rtc_failure` event's error, or a
709
+ * Playwright witness holding a rejection, wants the same narrowing
710
+ * the connect path gets.
711
+ */
712
+ async refineIceFailure(error) {
713
+ return refineIceFailure(error, {
714
+ bootstrapUrl: this.bootstrapUrl,
715
+ bootstrapObserved: this.bootstrapObserved,
716
+ anchorRtcAddr: this.anchorRtcAddr,
717
+ failureTyping: this.failureTyping,
718
+ });
719
+ }
720
+ /**
721
+ * Close the node, its streams and its event surface.
722
+ *
723
+ * **Every stream this node handed out is ended first**, and that
724
+ * is a behaviour change: an iterator parked in `for await`
725
+ * completes here rather than hanging forever on a node that will
726
+ * never emit again, and `onMessage` listeners are dropped. It is
727
+ * the disposition the leader-proxied surface already gave its
728
+ * streams on a generation change (`MeshSession.endStreams`), so
729
+ * the two surfaces now dispose of a stream the same way; the
730
+ * direct one was the outlier.
731
+ *
732
+ * The order is load-bearing: the streams go before
733
+ * `inner.close()`, because a wasm `LeafStream.close` retires its
734
+ * handle *through the node* and a node already closed would
735
+ * report that as a failed close instead of performing it.
736
+ *
737
+ * **Every one of those closes is attempted.** `close` latches and
738
+ * empties its set before the loop, so there is no second chance at
739
+ * any of it: a child whose `close` threw out of the loop left the
740
+ * next child open with its iterator parked forever, the hub
741
+ * dispatching, the leaf never closed — and the retry a page then
742
+ * makes returns immediately, because the latch is set. So each
743
+ * obligation is discharged independently and the failures are
744
+ * collected, which is the only reading under which the latch is
745
+ * honest.
746
+ *
747
+ * Reported, not swallowed: if anything threw, this throws an
748
+ * `AggregateError` whose `errors` are the {@link LeafError}s in
749
+ * teardown order — always an aggregate, however many failed,
750
+ * because "how many parts of the close failed" is not a shape a
751
+ * caller should have to branch on to find out that one did. A
752
+ * host-supplied {@link LeafWasmStreamLike} is the surface that
753
+ * makes this reachable; the leaf's own `close` does not throw.
754
+ */
755
+ close() {
756
+ if (this.closed)
757
+ return;
758
+ this.closed = true;
759
+ const failures = [];
760
+ // Ownership retirement first, while the node can still put a
761
+ // CANCEL on the wire (plan §4.5): one per dropped caller handle,
762
+ // each consumer settled with its typed terminal.
763
+ for (const failure of this.orgHandles.retireAll(new OrgCancelledError('org:rpc:cancelled: the node is closed'))) {
764
+ failures.push(fromWasmError(failure));
765
+ }
766
+ const open = [...this.streams];
767
+ this.streams.clear();
768
+ for (const stream of open) {
769
+ try {
770
+ stream.close();
771
+ }
772
+ catch (error) {
773
+ failures.push(fromWasmError(error));
774
+ }
775
+ }
776
+ try {
777
+ this.hub.close();
778
+ }
779
+ catch (error) {
780
+ failures.push(fromWasmError(error));
781
+ }
782
+ try {
783
+ this.inner.close();
784
+ }
785
+ catch (error) {
786
+ failures.push(fromWasmError(error));
787
+ }
788
+ if (failures.length > 0) {
789
+ throw new AggregateError(failures, `close: ${failures.length} of this node's teardown steps failed`);
790
+ }
791
+ }
792
+ /**
793
+ * The one `on_event` callback. Two events teach the probe something:
794
+ * `connected` proves the HTTPS bootstrap worked and carries the
795
+ * `rtc_addr` the anchor published, which is the only address this
796
+ * package may aim a STUN probe at.
797
+ */
798
+ ingest(json) {
799
+ const event = parseEvent(json);
800
+ if (event.type === 'connected') {
801
+ this.bootstrapObserved = true;
802
+ if (event.rtcAddr !== null)
803
+ this.anchorRtcAddr = event.rtcAddr;
804
+ }
805
+ this.hub.dispatch(event);
806
+ }
807
+ }
808
+ /**
809
+ * Connect to the mesh through an anchor.
810
+ *
811
+ * Loads and instantiates the leaf wasm (once per page, whatever the
812
+ * number of `connect()` calls), hands it the credential, and resolves a
813
+ * {@link BrowserNode}. Rejects with a typed {@link LeafError}; an ICE
814
+ * failure is classified per {@link BrowserNode.refineIceFailure}.
815
+ */
816
+ export async function connect(options) {
817
+ const request = buildConnectRequest(options);
818
+ const failureTyping = options.failureTyping ?? {};
819
+ const wasm = await loadLeafWasm(options);
820
+ try {
821
+ const inner = await wasm.LeafNode.connect(request);
822
+ return new BrowserNode(inner, options.bootstrapUrl ?? null, failureTyping, options.anchorRtcAddr ?? null);
823
+ }
824
+ catch (error) {
825
+ throw await refineIceFailure(fromWasmError(error), {
826
+ bootstrapUrl: options.bootstrapUrl ?? null,
827
+ bootstrapObserved: false,
828
+ anchorRtcAddr: options.anchorRtcAddr ?? null,
829
+ failureTyping,
830
+ });
831
+ }
832
+ }
833
+ /**
834
+ * Build exactly the options object `LeafNode.connect` reads, credential
835
+ * resolved, origin defaulted, custodial identity validated.
836
+ *
837
+ * Exported because `src/leader/` builds `MeshSession.open`'s options on
838
+ * top of it: the two surfaces take the same connect keys, and a key
839
+ * added here must not be silently missing there. A dropped option is
840
+ * invisible from a page — it has already cost one witness — so the two
841
+ * share the builder rather than each listing the keys.
842
+ */
843
+ export function buildConnectRequest(options) {
844
+ return {
845
+ credentialB64: resolveCredential(options),
846
+ origin: options.origin ?? defaultOrigin(),
847
+ ...(options.bootstrapUrl === undefined ? {} : { bootstrapUrl: options.bootstrapUrl }),
848
+ ...(options.iceServers ? { iceServers: options.iceServers } : {}),
849
+ ...resolveCustodialIdentity(options),
850
+ };
851
+ }
852
+ /**
853
+ * Parse a `counters_json()` payload. Values stay strings because they
854
+ * are `u64` on the Rust side and a JS number would round the large
855
+ * ones. Exported for the session surface, whose `counters_json` is a
856
+ * promise but whose payload is identical.
857
+ */
858
+ export function parseCounters(json) {
859
+ const parsed = JSON.parse(json);
860
+ if (parsed === null || typeof parsed !== 'object')
861
+ return {};
862
+ const out = {};
863
+ for (const [key, value] of Object.entries(parsed)) {
864
+ out[key] = typeof value === 'string' ? value : String(value);
865
+ }
866
+ return out;
867
+ }
868
+ /**
869
+ * Parse one `peer_candidate` reading.
870
+ *
871
+ * `remainingMs` stays a string for the reason every other `u64` on
872
+ * this boundary does, and `state` is validated rather than cast: an
873
+ * unrecognised state would otherwise fall through both the
874
+ * `iceTimeout` and the `open` arm of a drive loop and spin until the
875
+ * leaf's own deadline, which is the slowest possible way to report a
876
+ * boundary that changed shape.
877
+ *
878
+ * `candidateError` is carried through as a value. The leaf warns it
879
+ * to the console as well, but a console line is not something a
880
+ * page, a witness or a diagnostic can read — and this parser used to
881
+ * drop the field, which made the leaf's machine-readable diagnostic
882
+ * unreachable from TypeScript.
883
+ *
884
+ * Exported for the unit tests, which drive it with fixed payloads.
885
+ */
886
+ export function parseAttemptStatus(json) {
887
+ const parsed = JSON.parse(json);
888
+ if (parsed === null || typeof parsed !== 'object') {
889
+ throw new TypeError(`peer_candidate did not answer an object: ${json}`);
890
+ }
891
+ const raw = parsed;
892
+ const state = raw.state;
893
+ if (state !== 'gathering' &&
894
+ state !== 'open' &&
895
+ state !== 'iceTimeout' &&
896
+ state !== 'udpBlocked' &&
897
+ state !== 'failed') {
898
+ throw new TypeError(`peer_candidate answered an unknown state: ${String(state)}`);
899
+ }
900
+ const candidateError = raw.candidateError;
901
+ return {
902
+ dialog: String(raw.dialog ?? ''),
903
+ state,
904
+ sent: Number(raw.sent ?? 0),
905
+ applied: Number(raw.applied ?? 0),
906
+ answered: raw.answered === true,
907
+ direct: raw.direct === true,
908
+ remainingMs: String(raw.remainingMs ?? '0'),
909
+ candidateError: typeof candidateError === 'string' ? candidateError : null,
910
+ };
911
+ }
912
+ /**
913
+ * Parse an `rtc_stats_json()` payload.
914
+ *
915
+ * `not_applicable` is lifted out rather than left among the
916
+ * counters: a consumer iterating the reading must not find a field
917
+ * whose value is a sentence where every other value is a number.
918
+ *
919
+ * Exported for the unit tests, which drive it with fixed payloads.
920
+ */
921
+ export function parseRtcStats(json) {
922
+ const parsed = JSON.parse(json);
923
+ if (parsed === null || typeof parsed !== 'object') {
924
+ throw new TypeError(`rtc_stats_json did not answer an object: ${json}`);
925
+ }
926
+ const counters = {};
927
+ const notApplicable = {};
928
+ for (const [key, value] of Object.entries(parsed)) {
929
+ if (key === 'not_applicable') {
930
+ if (value === null || typeof value !== 'object')
931
+ continue;
932
+ for (const [field, reason] of Object.entries(value)) {
933
+ notApplicable[field] = String(reason);
934
+ }
935
+ continue;
936
+ }
937
+ counters[key] = typeof value === 'string' ? value : String(value);
938
+ }
939
+ return { counters, notApplicable };
940
+ }
941
+ /**
942
+ * Parse a `retry_report()` payload.
943
+ *
944
+ * Counts stay strings because they are `u64`, for the reason
945
+ * {@link parseCounters} gives. Exported for the unit tests.
946
+ */
947
+ export function parseRetryReport(json) {
948
+ const parsed = JSON.parse(json);
949
+ if (parsed === null || typeof parsed !== 'object') {
950
+ throw new TypeError(`retry_report did not answer an object: ${json}`);
951
+ }
952
+ const raw = parsed;
953
+ return {
954
+ armed: raw.armed === true,
955
+ online: String(raw.online ?? '0'),
956
+ iceFailed: String(raw.iceFailed ?? '0'),
957
+ triggers: String(raw.triggers ?? '0'),
958
+ started: String(raw.started ?? '0'),
959
+ coalesced: String(raw.coalesced ?? '0'),
960
+ notEligible: String(raw.notEligible ?? '0'),
961
+ openEpisodes: Number(raw.openEpisodes ?? 0),
962
+ owned: Number(raw.owned ?? 0),
963
+ last: typeof raw.last === 'string' ? raw.last : null,
964
+ };
965
+ }
966
+ /**
967
+ * The orchestration half of the correction: gather the observations,
968
+ * then let the pure classifier decide. Exported for the unit tests,
969
+ * which drive it with fake probes.
970
+ */
971
+ export async function refineIceFailure(error, context) {
972
+ if (!(error instanceof RtcError) || error.failure.type !== 'iceTimeout')
973
+ return error;
974
+ if (context.failureTyping.probeOnIceTimeout === false)
975
+ return error;
976
+ const probed = context.anchorRtcAddr;
977
+ if (probed === null) {
978
+ // No subject, no claim. The plan's whole point: an ICE timeout
979
+ // alone is never promoted.
980
+ return error;
981
+ }
982
+ const stunProbe = await probeStunBinding(probed, context.failureTyping.stun);
983
+ if (stunProbe.type !== 'unanswered')
984
+ return error;
985
+ const bootstrapOk = context.bootstrapObserved ||
986
+ (context.bootstrapUrl !== null &&
987
+ (await probeBootstrapReachable(context.bootstrapUrl, context.failureTyping.bootstrap)));
988
+ return classifyRtcError({ bootstrapOk, stunProbe, probed });
989
+ }
990
+ /**
991
+ * Parse `query`'s JSON array — `{ node_id, entity_id, capabilities,
992
+ * rtc_addr, noise_pubkey, version }` per entry, with `node_id` and
993
+ * `version` kept as exact decimal strings. Exported for the unit tests.
994
+ *
995
+ * `peerIdHex` is derived beside the decimal `nodeId`, never instead
996
+ * of it: the peer methods take hex and the announcement carries
997
+ * decimal, and a consumer that cannot see both cannot tell which it
998
+ * is holding. A `node_id` that is not a decimal `u64` is a boundary
999
+ * that changed shape and is refused rather than turned into an
1000
+ * address.
1001
+ */
1002
+ export function parseDescriptors(json) {
1003
+ const parsed = parseJsonPreservingU64(json);
1004
+ if (!Array.isArray(parsed))
1005
+ return [];
1006
+ const out = [];
1007
+ for (const entry of parsed) {
1008
+ if (entry === null || typeof entry !== 'object')
1009
+ continue;
1010
+ const fields = { ...entry };
1011
+ const capabilities = fields.capabilities;
1012
+ const nodeId = exactId(fields.node_id);
1013
+ out.push({
1014
+ nodeId,
1015
+ peerIdHex: peerIdHex(nodeId),
1016
+ entityId: text(fields.entity_id),
1017
+ capabilities: Array.isArray(capabilities)
1018
+ ? capabilities.filter((item) => typeof item === 'string')
1019
+ : [],
1020
+ rtcAddr: text(fields.rtc_addr),
1021
+ noisePubkey: text(fields.noise_pubkey),
1022
+ version: text(fields.version),
1023
+ });
1024
+ }
1025
+ return out;
1026
+ }
1027
+ /** A 32-byte secret is 64 hex digits, nothing else. */
1028
+ const SECRET_HEX = /^[0-9a-fA-F]{64}$/;
1029
+ /**
1030
+ * Validate the custodial identity options and return exactly the keys
1031
+ * the wasm side reads.
1032
+ *
1033
+ * **Loud, never silent.** A malformed secret, or a Noise half without
1034
+ * an entity half, throws {@link IdentityError} here rather than
1035
+ * reaching the leaf — the same rule the Rust side now follows. The
1036
+ * failure this guards against has already cost a witness once: a
1037
+ * dropped or unusable identity option that falls through to a
1038
+ * generated identity looks like two tabs disagreeing about who they
1039
+ * are, with nothing in either log saying why.
1040
+ */
1041
+ function resolveCustodialIdentity(options) {
1042
+ const { entitySecretHex, noiseSecretHex } = options;
1043
+ if (entitySecretHex === undefined) {
1044
+ if (noiseSecretHex !== undefined) {
1045
+ throw new IdentityError('noiseSecretHex was supplied without entitySecretHex: the leaf would generate the entity half, ' +
1046
+ 'so the identity would not be the one you meant to inject');
1047
+ }
1048
+ return {};
1049
+ }
1050
+ if (!SECRET_HEX.test(entitySecretHex)) {
1051
+ throw new IdentityError(`entitySecretHex must be 32 bytes of hex (64 hex digits), got ${entitySecretHex.length} characters`);
1052
+ }
1053
+ if (noiseSecretHex === undefined)
1054
+ return { entitySecretHex };
1055
+ if (!SECRET_HEX.test(noiseSecretHex)) {
1056
+ throw new IdentityError(`noiseSecretHex must be 32 bytes of hex (64 hex digits), got ${noiseSecretHex.length} characters`);
1057
+ }
1058
+ return { entitySecretHex, noiseSecretHex };
1059
+ }
1060
+ function exactId(value) {
1061
+ if (typeof value === 'string')
1062
+ return value;
1063
+ return typeof value === 'number' ? String(value) : '';
1064
+ }
1065
+ function text(value) {
1066
+ if (typeof value === 'string')
1067
+ return value.length > 0 ? value : null;
1068
+ return typeof value === 'number' ? String(value) : null;
1069
+ }
1070
+ function resolveCredential(options) {
1071
+ if (options.credentialB64 !== undefined)
1072
+ return options.credentialB64;
1073
+ const credential = options.credential;
1074
+ if (typeof credential === 'string')
1075
+ return credential;
1076
+ if (credential instanceof Uint8Array)
1077
+ return toBase64(credential);
1078
+ throw new Error('connect() needs a credential: pass credentialB64 or credential');
1079
+ }
1080
+ function defaultOrigin() {
1081
+ const origin = globalThis.location?.origin;
1082
+ if (typeof origin === 'string' && origin.length > 0)
1083
+ return origin;
1084
+ throw new Error('connect() needs an origin: there is no location.origin in this context');
1085
+ }