@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.
- package/CHANGELOG.md +517 -0
- package/LICENSE-APACHE +190 -0
- package/LICENSE-MIT +21 -0
- package/README.md +666 -0
- package/dist/async-queue.d.ts +26 -0
- package/dist/async-queue.js +62 -0
- package/dist/credential.d.ts +70 -0
- package/dist/credential.js +95 -0
- package/dist/errors.d.ts +400 -0
- package/dist/errors.js +726 -0
- package/dist/events.d.ts +232 -0
- package/dist/events.js +298 -0
- package/dist/identity.d.ts +40 -0
- package/dist/identity.js +78 -0
- package/dist/index.bundle.js +2 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +46 -0
- package/dist/leader/events.d.ts +111 -0
- package/dist/leader/events.js +116 -0
- package/dist/leader/index.d.ts +14 -0
- package/dist/leader/index.js +11 -0
- package/dist/leader/session.d.ts +539 -0
- package/dist/leader/session.js +770 -0
- package/dist/leader/wasm.d.ts +123 -0
- package/dist/leader/wasm.js +39 -0
- package/dist/lobby.d.ts +183 -0
- package/dist/lobby.js +504 -0
- package/dist/local.d.ts +80 -0
- package/dist/local.js +160 -0
- package/dist/net_leaf.d.ts +1494 -0
- package/dist/net_leaf.js +3753 -0
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +137 -0
- package/dist/netcode/client.d.ts +102 -0
- package/dist/netcode/client.js +296 -0
- package/dist/netcode/clock.d.ts +45 -0
- package/dist/netcode/clock.js +54 -0
- package/dist/netcode/host.d.ts +118 -0
- package/dist/netcode/host.js +276 -0
- package/dist/netcode/index.d.ts +19 -0
- package/dist/netcode/index.js +14 -0
- package/dist/netcode/interpolate.d.ts +50 -0
- package/dist/netcode/interpolate.js +100 -0
- package/dist/netcode/wire.d.ts +125 -0
- package/dist/netcode/wire.js +117 -0
- package/dist/node.d.ts +908 -0
- package/dist/node.js +1085 -0
- package/dist/org.d.ts +412 -0
- package/dist/org.js +567 -0
- package/dist/peer-driver.d.ts +117 -0
- package/dist/peer-driver.js +224 -0
- package/dist/store/assembly.d.ts +176 -0
- package/dist/store/assembly.js +325 -0
- package/dist/store/chunker.d.ts +83 -0
- package/dist/store/chunker.js +190 -0
- package/dist/store/core.d.ts +136 -0
- package/dist/store/core.js +477 -0
- package/dist/store/definition.d.ts +23 -0
- package/dist/store/definition.js +83 -0
- package/dist/store/errors.d.ts +77 -0
- package/dist/store/errors.js +94 -0
- package/dist/store/host.d.ts +247 -0
- package/dist/store/host.js +675 -0
- package/dist/store/index.d.ts +50 -0
- package/dist/store/index.js +40 -0
- package/dist/store/interest.d.ts +40 -0
- package/dist/store/interest.js +77 -0
- package/dist/store/inventory.d.ts +81 -0
- package/dist/store/inventory.js +140 -0
- package/dist/store/join.d.ts +118 -0
- package/dist/store/join.js +724 -0
- package/dist/store/json.d.ts +50 -0
- package/dist/store/json.js +283 -0
- package/dist/store/ledger.d.ts +187 -0
- package/dist/store/ledger.js +292 -0
- package/dist/store/owner.d.ts +616 -0
- package/dist/store/owner.js +1711 -0
- package/dist/store/patch.d.ts +70 -0
- package/dist/store/patch.js +166 -0
- package/dist/store/player.d.ts +65 -0
- package/dist/store/player.js +261 -0
- package/dist/store/replica.d.ts +187 -0
- package/dist/store/replica.js +740 -0
- package/dist/store/state.d.ts +42 -0
- package/dist/store/state.js +165 -0
- package/dist/store/types.d.ts +200 -0
- package/dist/store/types.js +25 -0
- package/dist/store/visibility.d.ts +118 -0
- package/dist/store/visibility.js +282 -0
- package/dist/store/wire.d.ts +309 -0
- package/dist/store/wire.js +834 -0
- package/dist/stream.d.ts +174 -0
- package/dist/stream.js +238 -0
- package/dist/three/index.d.ts +109 -0
- package/dist/three/index.js +130 -0
- package/dist/udp-probe.d.ts +159 -0
- package/dist/udp-probe.js +245 -0
- package/dist/wasm.d.ts +642 -0
- package/dist/wasm.js +98 -0
- package/dist/world/border.d.ts +84 -0
- package/dist/world/border.js +99 -0
- package/dist/world/handoff.d.ts +187 -0
- package/dist/world/handoff.js +222 -0
- package/dist/world/index.d.ts +13 -0
- package/dist/world/index.js +9 -0
- package/dist/world/link.d.ts +179 -0
- package/dist/world/link.js +476 -0
- package/dist/world/regions.d.ts +154 -0
- package/dist/world/regions.js +314 -0
- 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
|
+
}
|