@robota-sdk/agent-transport-webrtc 3.0.0-beta.100

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 (84) hide show
  1. package/CHANGELOG.md +694 -0
  2. package/LICENSE +661 -0
  3. package/README.md +111 -0
  4. package/dist/node/index.cjs +1 -0
  5. package/dist/node/index.d.cts +1650 -0
  6. package/dist/node/index.d.cts.map +1 -0
  7. package/dist/node/index.d.ts +1650 -0
  8. package/dist/node/index.d.ts.map +1 -0
  9. package/dist/node/index.js +2 -0
  10. package/dist/node/index.js.map +1 -0
  11. package/package.json +93 -0
  12. package/src/__tests__/admission-secure-by-default.test.ts +63 -0
  13. package/src/__tests__/admission-steps.test.ts +46 -0
  14. package/src/__tests__/device-mesh-relay.test.ts +441 -0
  15. package/src/__tests__/device-mesh.test.ts +682 -0
  16. package/src/__tests__/discovering-mesh-relay.test.ts +234 -0
  17. package/src/__tests__/enrollment-link.test.ts +103 -0
  18. package/src/__tests__/fake-datachannel.ts +83 -0
  19. package/src/__tests__/handoff-grant-gate.test.ts +403 -0
  20. package/src/__tests__/local-peer-proof-gate.test.ts +215 -0
  21. package/src/__tests__/mesh-admission-contract.test.ts +38 -0
  22. package/src/__tests__/mesh-discovery.test.ts +820 -0
  23. package/src/__tests__/mesh-fixtures.ts +148 -0
  24. package/src/__tests__/mesh-internet.test.ts +942 -0
  25. package/src/__tests__/mesh-turn-relay.test.ts +161 -0
  26. package/src/__tests__/negotiated-certificate.test.ts +76 -0
  27. package/src/__tests__/nostr-source-condition.test.ts +27 -0
  28. package/src/__tests__/operator-approval-gate.test.ts +308 -0
  29. package/src/__tests__/outbound-delivery-carrier.test.ts +155 -0
  30. package/src/__tests__/pairing-e2e.test.ts +237 -0
  31. package/src/__tests__/pairing-gate-e3.test.ts +194 -0
  32. package/src/__tests__/pairing-gate-e4.test.ts +110 -0
  33. package/src/__tests__/pairing-gate.test.ts +296 -0
  34. package/src/__tests__/pairing-relay-in-the-middle.test.ts +225 -0
  35. package/src/__tests__/rtc-channel-close.test.ts +52 -0
  36. package/src/__tests__/rtc-peer-candidates.test.ts +61 -0
  37. package/src/__tests__/session-attachment-app-view.test.ts +66 -0
  38. package/src/__tests__/session-attachment-budget.test.ts +136 -0
  39. package/src/__tests__/turn-server-bind.test.ts +59 -0
  40. package/src/__tests__/turn-server.test.ts +335 -0
  41. package/src/__tests__/turn-test-client.ts +256 -0
  42. package/src/__tests__/webrtc-implementation.test.ts +83 -0
  43. package/src/__tests__/webrtc-transport.test.ts +349 -0
  44. package/src/__tests__/ws-mesh-relay-client.test.ts +95 -0
  45. package/src/__tests__/ws-signaling-client.test.ts +108 -0
  46. package/src/admission-steps.ts +55 -0
  47. package/src/channel-delivery.ts +44 -0
  48. package/src/datachannel-loader.ts +86 -0
  49. package/src/device-mesh-node.ts +1019 -0
  50. package/src/discovering-mesh-relay.ts +675 -0
  51. package/src/enrollment-link.ts +465 -0
  52. package/src/handoff-grant-gate.ts +186 -0
  53. package/src/index.ts +139 -0
  54. package/src/local-peer-proof.ts +135 -0
  55. package/src/mainline-dht-store.ts +174 -0
  56. package/src/mesh-dht.ts +446 -0
  57. package/src/mesh-discovery.ts +87 -0
  58. package/src/mesh-item-store.ts +287 -0
  59. package/src/mesh-lan-listener.ts +193 -0
  60. package/src/mesh-mdns.ts +406 -0
  61. package/src/mesh-peer-link.ts +600 -0
  62. package/src/mesh-public-relays.ts +25 -0
  63. package/src/mesh-records.ts +357 -0
  64. package/src/mesh-relay.ts +98 -0
  65. package/src/mesh-signal.ts +116 -0
  66. package/src/mesh-turn-relay.ts +238 -0
  67. package/src/negotiated-certificate.ts +72 -0
  68. package/src/nostr-mesh-relay.ts +391 -0
  69. package/src/pairing-channel-lifecycle.ts +31 -0
  70. package/src/pairing-controllers.ts +81 -0
  71. package/src/pairing-frames.ts +34 -0
  72. package/src/pairing-gate-options.ts +133 -0
  73. package/src/pairing-gate.ts +397 -0
  74. package/src/rtc-peer.ts +429 -0
  75. package/src/session-attachment.ts +96 -0
  76. package/src/signaling.ts +62 -0
  77. package/src/stun-message.ts +341 -0
  78. package/src/transport-lifecycle-error.ts +11 -0
  79. package/src/turn-server.ts +1028 -0
  80. package/src/webrtc-delivery-lifecycle.ts +54 -0
  81. package/src/webrtc-transport-options.ts +87 -0
  82. package/src/webrtc-transport.ts +368 -0
  83. package/src/ws-mesh-relay-client.ts +117 -0
  84. package/src/ws-signaling-client.ts +139 -0
@@ -0,0 +1,675 @@
1
+ /**
2
+ * A mesh relay that looks for each peer itself before it falls back to a relay: the candidate
3
+ * sources in order (the address cache, mDNS, then public records), each candidate probed at the
4
+ * peer's direct signaling endpoint, and when none answers the signaling carriers in order (public
5
+ * Nostr relays, then the self-hosted relay).
6
+ *
7
+ * Every way a signal can go is equally untrusted. Discovery only chooses where the signals of a
8
+ * pair go; the node decodes whatever arrives as hostile input and admits a peer by the device
9
+ * handshake alone, so a stale, forged or hijacked candidate can delay a connection but never admit
10
+ * one. A candidate carries a pair's signals only once it proves it holds the pair's topic, and only
11
+ * until an attempt over it fails to reach admission in time; then it is set aside and the next way
12
+ * is tried, so a hostile endpoint cannot hold a pair off the relay. The time to admission counts from
13
+ * a signal that can only precede one, so what trails an admitted attempt cannot set a working way aside;
14
+ * a way that fails again is set aside for longer each time. A carrier that is not the last one is set
15
+ * aside the same way, so relays that drop signals cannot hold a pair off the next carrier. On
16
+ * the local network signals are addressed by rotating pairwise tags rather than the relay's inbox
17
+ * topics. A candidate is remembered only once a connection it carried was admitted.
18
+ */
19
+ import { rendezvousEpoch, RENDEZVOUS_EPOCH_MS } from '@robota-sdk/agent-remote-pairing';
20
+ import WebSocket from 'ws';
21
+
22
+ import {
23
+ addressCacheSource,
24
+ createInMemoryMeshAddressCache,
25
+ type IMeshAddressCache,
26
+ type IMeshCandidate,
27
+ type IMeshCandidateSource,
28
+ type IMeshPeerRoute,
29
+ } from './mesh-discovery.js';
30
+ import {
31
+ lanAddressOf,
32
+ startMeshLanListener,
33
+ verifyLanPresenceProof,
34
+ type IMeshLanListener,
35
+ } from './mesh-lan-listener.js';
36
+ import { MeshMdns, type IMeshMdnsOptions } from './mesh-mdns.js';
37
+ import type { MeshDht } from './mesh-dht.js';
38
+ import type { IMeshRelay } from './mesh-relay.js';
39
+ import type { IWebSocketLike } from './ws-signaling-client.js';
40
+
41
+ const WS_OPEN = 1;
42
+ /** How long a candidate has to answer a probe. */
43
+ const DEFAULT_PROBE_TIMEOUT_MS = 1_500;
44
+ /** How long one candidate source may look. */
45
+ const DEFAULT_SOURCE_TIMEOUT_MS = 1_500;
46
+ /** After every candidate failed, the relay carries the pair's signals this long before looking again. */
47
+ const DEFAULT_RELAY_RECHECK_MS = 30_000;
48
+ /** Candidates probed per lookup, across all sources. */
49
+ const MAX_PROBES = 8;
50
+ /** Signals held for a pair while its way is being looked for. */
51
+ const MAX_QUEUED = 256;
52
+ /** A direct path that has not carried an admission in this long is set aside. */
53
+ const DEFAULT_ADMISSION_TIMEOUT_MS = 20_000;
54
+ /** A way that keeps failing is set aside for at most this many recheck periods at a time. */
55
+ const MAX_SET_ASIDE_FACTOR = 16;
56
+ /** Ways whose failures a pair remembers; the oldest is forgotten first. */
57
+ const MAX_REMEMBERED_FAILURES = 32;
58
+
59
+ /** Announces this device to its peers somewhere they look. */
60
+ export interface IMeshAdvertiser {
61
+ advertise(routes: readonly IMeshPeerRoute[], port: number): Promise<void>;
62
+ close(): void;
63
+ }
64
+
65
+ export interface IDiscoveringMeshRelayOptions {
66
+ /**
67
+ * The self-hosted relay: the last resort. Absent with no `signaling` either: a pair no candidate
68
+ * reaches cannot be signaled.
69
+ */
70
+ readonly relay?: IMeshRelay;
71
+ /** Public signaling carriers, tried in order before `relay` when no candidate answers. */
72
+ readonly signaling?: readonly IMeshRelay[];
73
+ /** This device's direct signaling endpoint. */
74
+ readonly listener: IMeshLanListener;
75
+ /** Where to look for a peer's endpoint, in order. */
76
+ readonly sources: readonly IMeshCandidateSource[];
77
+ /** Remembers candidates that carried an admitted connection. */
78
+ readonly cache?: IMeshAddressCache;
79
+ /** Told the peers and this device's endpoint port, to announce this device; closed with this relay. */
80
+ readonly advertisers?: readonly IMeshAdvertiser[];
81
+ /** Test seam: open a socket to a candidate. */
82
+ readonly connect?: (candidate: IMeshCandidate) => IWebSocketLike;
83
+ readonly probeTimeoutMs?: number;
84
+ readonly sourceTimeoutMs?: number;
85
+ readonly relayRecheckMs?: number;
86
+ /** How long a direct path may carry signals without an admission before it is set aside. */
87
+ readonly admissionTimeoutMs?: number;
88
+ readonly onError?: (error: Error) => void;
89
+ readonly now?: () => number;
90
+ }
91
+
92
+ type TPath =
93
+ | { readonly kind: 'unresolved' }
94
+ | { readonly kind: 'resolving'; readonly queue: unknown[] }
95
+ | {
96
+ readonly kind: 'direct';
97
+ readonly socket: IWebSocketLike;
98
+ readonly candidate: IMeshCandidate;
99
+ /** Running from the first attempt since the last admission until the next admission. */
100
+ deadline?: ReturnType<typeof setTimeout>;
101
+ }
102
+ | {
103
+ readonly kind: 'relay';
104
+ readonly until: number;
105
+ readonly carrier: IMeshRelay;
106
+ /** As a direct path's, unless this is the last carrier. */
107
+ deadline?: ReturnType<typeof setTimeout>;
108
+ };
109
+
110
+ /** Ways set aside because they carried no admission, and how often each has failed in a row. */
111
+ class SetAside<K> {
112
+ private readonly until = new Map<K, number>();
113
+ private readonly failures = new Map<K, number>();
114
+
115
+ /** Whether `key` is set aside at `now`. */
116
+ public has(key: K, now: number): boolean {
117
+ const until = this.until.get(key);
118
+ if (until === undefined) return false;
119
+ if (until > now) return true;
120
+ this.until.delete(key);
121
+ return false;
122
+ }
123
+
124
+ /** `key` failed again: set aside twice as long as the last time, within the bound. */
125
+ public fail(key: K, now: number, periodMs: number): void {
126
+ const failures = (this.failures.get(key) ?? 0) + 1;
127
+ this.failures.delete(key);
128
+ this.failures.set(key, failures);
129
+ for (const oldest of this.failures.keys()) {
130
+ if (this.failures.size <= MAX_REMEMBERED_FAILURES) break;
131
+ this.failures.delete(oldest);
132
+ this.until.delete(oldest);
133
+ }
134
+ const factor = Math.min(2 ** (failures - 1), MAX_SET_ASIDE_FACTOR);
135
+ this.until.set(key, now + periodMs * factor);
136
+ }
137
+
138
+ /** An admission: every way starts over. */
139
+ public clear(): void {
140
+ this.until.clear();
141
+ this.failures.clear();
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Whether a signal can only precede an attempt's admission: a `hello`, an `offer` or an `answer`.
147
+ * ICE candidates keep trickling after it.
148
+ */
149
+ function precedesAdmission(data: unknown): boolean {
150
+ if (typeof data !== 'object' || data === null) return false;
151
+ const kind = (data as { kind?: unknown }).kind;
152
+ return kind === 'hello' || kind === 'offer' || kind === 'answer';
153
+ }
154
+
155
+ interface IRouteState {
156
+ route: IMeshPeerRoute;
157
+ /** The topic this device's signals to the peer go to on the local network, this epoch. */
158
+ lanOut: string;
159
+ path: TPath;
160
+ /** Candidates (`host:port`) set aside, because they carried no admission. */
161
+ readonly avoid: SetAside<string>;
162
+ /** Carriers set aside, because they carried no admission. */
163
+ readonly avoidCarriers: SetAside<IMeshRelay>;
164
+ }
165
+
166
+ function candidateKey(candidate: IMeshCandidate): string {
167
+ return `${candidate.host}:${candidate.port}`;
168
+ }
169
+
170
+ function randomNonce(): string {
171
+ const bytes = new Uint8Array(16);
172
+ globalThis.crypto.getRandomValues(bytes);
173
+ return Buffer.from(bytes).toString('base64url');
174
+ }
175
+
176
+ /** A pairwise tag as a LAN topic. */
177
+ function toLanTopic(tag: Uint8Array): string {
178
+ return Buffer.from(tag).toString('base64url');
179
+ }
180
+
181
+ function candidateUrl(candidate: IMeshCandidate): string {
182
+ const host = candidate.host.includes(':') ? `[${candidate.host}]` : candidate.host;
183
+ return `ws://${host}:${candidate.port}`;
184
+ }
185
+
186
+ function parse(
187
+ raw: unknown,
188
+ ): { type?: unknown; to?: unknown; nonce?: unknown; proof?: unknown } | undefined {
189
+ try {
190
+ const frame: unknown = JSON.parse(String(raw));
191
+ return typeof frame === 'object' && frame !== null
192
+ ? (frame as Record<string, unknown>)
193
+ : undefined;
194
+ } catch {
195
+ return undefined;
196
+ }
197
+ }
198
+
199
+ export class DiscoveringMeshRelay implements IMeshRelay {
200
+ private readonly routes = new Map<string, IRouteState>();
201
+ private readonly byDevice = new Map<string, IRouteState>();
202
+ /** LAN topic → the relay inbox topic the node knows the pair by. */
203
+ private lanIn = new Map<string, string>();
204
+ private readonly messages = new Set<(topic: string, data: unknown) => void>();
205
+ private readonly absents = new Set<(topic: string) => void>();
206
+ private readonly unsubscribes: (() => void)[] = [];
207
+ private readonly abort = new AbortController();
208
+ private sync: Promise<void> = Promise.resolve();
209
+ /** Peer declarations not yet in force; signals sent meanwhile wait for them, in order. */
210
+ private pendingPeers = 0;
211
+ private waiting: { readonly topic: string; readonly data: unknown }[] = [];
212
+ private timer?: ReturnType<typeof setTimeout>;
213
+ private closed = false;
214
+
215
+ public constructor(private readonly options: IDiscoveringMeshRelayOptions) {
216
+ for (const carrier of this.carriers()) {
217
+ this.unsubscribes.push(
218
+ carrier.onMessage((topic, data) => this.deliver(topic, data)),
219
+ carrier.onAbsent((topic) => this.absent(topic)),
220
+ );
221
+ }
222
+ this.unsubscribes.push(
223
+ options.listener.onMessage((lanTopic, data) => {
224
+ const topic = this.lanIn.get(lanTopic);
225
+ if (topic !== undefined) this.deliver(topic, data);
226
+ }),
227
+ );
228
+ }
229
+
230
+ private now(): number {
231
+ return (this.options.now ?? Date.now)();
232
+ }
233
+
234
+ /** Where a pair's signals go when no candidate answers, in order. */
235
+ private carriers(): IMeshRelay[] {
236
+ return [
237
+ ...(this.options.signaling ?? []),
238
+ ...(this.options.relay !== undefined ? [this.options.relay] : []),
239
+ ];
240
+ }
241
+
242
+ private deliver(topic: string, data: unknown): void {
243
+ if (this.closed) return;
244
+ for (const handler of this.messages) handler(topic, data);
245
+ }
246
+
247
+ private absent(topic: string): void {
248
+ if (this.closed) return;
249
+ for (const handler of this.absents) handler(topic);
250
+ }
251
+
252
+ public declarePresence(topics: readonly string[]): void {
253
+ for (const carrier of this.carriers()) carrier.declarePresence(topics);
254
+ }
255
+
256
+ public declarePeers(peers: readonly IMeshPeerRoute[]): void {
257
+ for (const carrier of this.carriers()) carrier.declarePeers?.(peers);
258
+ this.pendingPeers += 1;
259
+ this.sync = this.sync
260
+ .then(() => this.applyPeers(peers))
261
+ .catch((error: unknown) => {
262
+ this.options.onError?.(error instanceof Error ? error : new Error(String(error)));
263
+ })
264
+ .finally(() => {
265
+ this.pendingPeers -= 1;
266
+ if (this.pendingPeers > 0) return;
267
+ for (const { topic, data } of this.waiting.splice(0)) this.send(topic, data);
268
+ });
269
+ }
270
+
271
+ /** Recompute the pairs' LAN topics for the current epoch and hold them. */
272
+ private async applyPeers(peers: readonly IMeshPeerRoute[]): Promise<void> {
273
+ if (this.closed) return;
274
+ const epoch = rendezvousEpoch(this.now());
275
+ const lanIn = new Map<string, string>();
276
+ const next = new Map<string, IRouteState>();
277
+ for (const route of peers) {
278
+ const outbound = toLanTopic(await route.rendezvous.tag('lan-inbox', 'outbound', epoch));
279
+ for (const tag of await route.rendezvous.lookupTags('lan-inbox', epoch)) {
280
+ lanIn.set(toLanTopic(tag), route.inbound);
281
+ }
282
+ const held = this.routes.get(route.outbound);
283
+ const state: IRouteState =
284
+ held !== undefined && held.route.deviceId === route.deviceId
285
+ ? held
286
+ : {
287
+ route,
288
+ lanOut: outbound,
289
+ path: { kind: 'unresolved' },
290
+ avoid: new SetAside(),
291
+ avoidCarriers: new SetAside(),
292
+ };
293
+ state.route = route;
294
+ state.lanOut = outbound;
295
+ next.set(route.outbound, state);
296
+ }
297
+ if (this.closed) return;
298
+ for (const [topic, state] of this.routes) {
299
+ if (next.get(topic) !== state) this.reset(state);
300
+ }
301
+ this.routes.clear();
302
+ this.byDevice.clear();
303
+ for (const [topic, state] of next) {
304
+ this.routes.set(topic, state);
305
+ this.byDevice.set(state.route.deviceId, state);
306
+ }
307
+ this.lanIn = lanIn;
308
+ this.options.listener.setPresence([...lanIn.keys()]);
309
+ this.cacheWrite(() => this.options.cache?.retain(peers.map((p) => p.deviceId)));
310
+ await Promise.all(
311
+ (this.options.advertisers ?? []).map((a) => a.advertise(peers, this.options.listener.port)),
312
+ );
313
+ if (this.closed) return;
314
+ this.scheduleEpoch(epoch, peers);
315
+ }
316
+
317
+ private scheduleEpoch(epoch: number, peers: readonly IMeshPeerRoute[]): void {
318
+ if (this.timer !== undefined) clearTimeout(this.timer);
319
+ const delay = Math.max(0, (epoch + 1) * RENDEZVOUS_EPOCH_MS - this.now());
320
+ this.timer = setTimeout(() => this.declarePeers(peers), delay);
321
+ this.timer.unref?.();
322
+ }
323
+
324
+ public confirmPeer(deviceId: string): void {
325
+ const state = this.byDevice.get(deviceId);
326
+ const path = state?.path;
327
+ if (state === undefined || (path?.kind !== 'direct' && path?.kind !== 'relay')) return;
328
+ clearTimeout(path.deadline);
329
+ path.deadline = undefined;
330
+ if (path.kind === 'relay') {
331
+ state.avoidCarriers.clear();
332
+ return;
333
+ }
334
+ state.avoid.clear();
335
+ this.cacheWrite(() => this.options.cache?.remember(deviceId, path.candidate));
336
+ }
337
+
338
+ /** A cache that cannot be written costs the next connection a lookup, nothing more. */
339
+ private cacheWrite(write: () => void): void {
340
+ try {
341
+ write();
342
+ } catch (error) {
343
+ this.options.onError?.(error instanceof Error ? error : new Error(String(error)));
344
+ }
345
+ }
346
+
347
+ public send(topic: string, data: unknown): void {
348
+ if (this.closed) return;
349
+ if (this.pendingPeers > 0) {
350
+ // The pair may be one being declared right now: route it once it is.
351
+ if (this.waiting.length < MAX_QUEUED) this.waiting.push({ topic, data });
352
+ else this.viaRelay(topic, data);
353
+ return;
354
+ }
355
+ const state = this.routes.get(topic);
356
+ if (state === undefined) {
357
+ this.viaRelay(topic, data);
358
+ return;
359
+ }
360
+ const path = state.path;
361
+ if (path.kind === 'direct' && path.socket.readyState === WS_OPEN) {
362
+ this.sendDirect(state, path, data);
363
+ return;
364
+ }
365
+ if (path.kind === 'relay' && this.now() < path.until) {
366
+ this.viaCarrier(state, path, data);
367
+ return;
368
+ }
369
+ if (path.kind === 'resolving') {
370
+ if (path.queue.length < MAX_QUEUED) path.queue.push(data);
371
+ else this.viaRelay(topic, data);
372
+ return;
373
+ }
374
+ if (path.kind === 'direct') this.reset(state);
375
+ const queue = [data];
376
+ state.path = { kind: 'resolving', queue };
377
+ void this.resolve(state, queue);
378
+ }
379
+
380
+ private sendDirect(
381
+ state: IRouteState,
382
+ path: Extract<TPath, { kind: 'direct' }>,
383
+ data: unknown,
384
+ ): void {
385
+ // Every attempt must reach admission over this endpoint in time, not only the first one.
386
+ if (precedesAdmission(data)) this.armDeadline(state, path);
387
+ path.socket.send(JSON.stringify({ type: 'message', to: lanAddressOf(state.lanOut), data }));
388
+ }
389
+
390
+ /** A signal for a topic that is no declared pair's: the last carrier, if there is one. */
391
+ private viaRelay(topic: string, data: unknown): void {
392
+ const carrier = this.carriers().at(-1);
393
+ if (carrier === undefined) {
394
+ queueMicrotask(() => this.absent(topic));
395
+ return;
396
+ }
397
+ carrier.send(topic, data);
398
+ }
399
+
400
+ private viaCarrier(
401
+ state: IRouteState,
402
+ path: Extract<TPath, { kind: 'relay' }>,
403
+ data: unknown,
404
+ ): void {
405
+ // Every attempt must reach admission over this carrier in time, unless it is the last one.
406
+ if (precedesAdmission(data) && path.carrier !== this.carriers().at(-1)) {
407
+ this.armDeadline(state, path);
408
+ }
409
+ path.carrier.send(state.route.outbound, data);
410
+ }
411
+
412
+ /** Try the sources in order; the first candidate that holds the pair's topic carries its signals. */
413
+ private async resolve(state: IRouteState, queue: unknown[]): Promise<void> {
414
+ const tried = new Set<string>();
415
+ for (const source of this.options.sources) {
416
+ if (this.closed || state.path.kind !== 'resolving') return;
417
+ for (const candidate of await this.look(source, state.route)) {
418
+ const key = candidateKey(candidate);
419
+ if (tried.size >= MAX_PROBES) break;
420
+ if (tried.has(key) || state.avoid.has(key, this.now())) continue;
421
+ tried.add(key);
422
+ const socket = await this.probe(candidate, state.lanOut);
423
+ if (socket === undefined) continue;
424
+ if (
425
+ this.closed ||
426
+ state.path.kind !== 'resolving' ||
427
+ this.routes.get(state.route.outbound) !== state
428
+ ) {
429
+ socket.close();
430
+ return;
431
+ }
432
+ const path = this.useDirect(state, socket, candidate);
433
+ for (const data of queue) this.sendDirect(state, path, data);
434
+ return;
435
+ }
436
+ }
437
+ if (this.closed || state.path.kind !== 'resolving') return;
438
+ this.fallBack(state);
439
+ const path = state.path as TPath;
440
+ for (const data of queue) {
441
+ if (path.kind === 'relay') this.viaCarrier(state, path, data);
442
+ else this.viaRelay(state.route.outbound, data);
443
+ }
444
+ }
445
+
446
+ /**
447
+ * The first carrier not set aside carries the pair's signals for a while (the last one when all
448
+ * are). Without a carrier there is nothing to wait on: the next signal looks again, since the
449
+ * peer's endpoint may simply not have been up yet.
450
+ */
451
+ private fallBack(state: IRouteState): void {
452
+ const now = this.now();
453
+ const carriers = this.carriers();
454
+ const carrier = carriers.find((c) => !state.avoidCarriers.has(c, now)) ?? carriers.at(-1);
455
+ state.path =
456
+ carrier === undefined
457
+ ? { kind: 'unresolved' }
458
+ : { kind: 'relay', until: now + this.recheckMs(), carrier };
459
+ }
460
+
461
+ private recheckMs(): number {
462
+ return this.options.relayRecheckMs ?? DEFAULT_RELAY_RECHECK_MS;
463
+ }
464
+
465
+ private async look(
466
+ source: IMeshCandidateSource,
467
+ route: IMeshPeerRoute,
468
+ ): Promise<readonly IMeshCandidate[]> {
469
+ const timeout = AbortSignal.timeout(
470
+ source.timeoutMs ?? this.options.sourceTimeoutMs ?? DEFAULT_SOURCE_TIMEOUT_MS,
471
+ );
472
+ const signal = AbortSignal.any([timeout, this.abort.signal]);
473
+ try {
474
+ return await Promise.race([
475
+ source.candidates(route, signal),
476
+ new Promise<readonly IMeshCandidate[]>((resolve) =>
477
+ signal.addEventListener('abort', () => resolve([]), { once: true }),
478
+ ),
479
+ ]);
480
+ } catch {
481
+ // allow-fallback: a source that fails has no candidates; the next source, then the relay, is tried
482
+ return [];
483
+ }
484
+ }
485
+
486
+ /** A socket to `candidate` once it proves it holds `lanTopic`; `undefined` otherwise. */
487
+ private probe(candidate: IMeshCandidate, lanTopic: string): Promise<IWebSocketLike | undefined> {
488
+ const to = lanAddressOf(lanTopic);
489
+ const nonce = randomNonce();
490
+ return new Promise((resolve) => {
491
+ let socket: IWebSocketLike;
492
+ try {
493
+ socket = (
494
+ this.options.connect ??
495
+ ((c) => new WebSocket(candidateUrl(c), { maxPayload: 4096 }) as unknown as IWebSocketLike)
496
+ )(candidate);
497
+ } catch {
498
+ resolve(undefined);
499
+ return;
500
+ }
501
+ let settled = false;
502
+ const settle = (ok: boolean): void => {
503
+ if (settled) return;
504
+ settled = true;
505
+ clearTimeout(timer);
506
+ if (!ok) socket.close();
507
+ resolve(ok ? socket : undefined);
508
+ };
509
+ const timer = setTimeout(
510
+ () => settle(false),
511
+ this.options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
512
+ );
513
+ timer.unref?.();
514
+ socket.on('open', () => socket.send(JSON.stringify({ type: 'probe', to, nonce })));
515
+ socket.on('message', (raw) => {
516
+ const frame = parse(raw);
517
+ if (frame?.to !== to) return;
518
+ if (frame.type === 'absent') {
519
+ settle(false);
520
+ return;
521
+ }
522
+ // Whatever else the frame says, only the proof decides: it is an HMAC over this probe's
523
+ // own nonce, so an echoed, stale or forged frame fails it.
524
+ settle(verifyLanPresenceProof(lanTopic, nonce, frame.proof));
525
+ });
526
+ socket.on('error', () => settle(false));
527
+ socket.on('close', () => settle(false));
528
+ });
529
+ }
530
+
531
+ /**
532
+ * An endpoint or carrier that carries an attempt but no admission is set aside, so it cannot keep
533
+ * the pair from the next way — whether it never forwarded, or forwarded one attempt and swallows
534
+ * the next. One that fails again is set aside for longer, so it is not tried every recheck.
535
+ */
536
+ private armDeadline(
537
+ state: IRouteState,
538
+ path: Extract<TPath, { kind: 'direct' } | { kind: 'relay' }>,
539
+ ): void {
540
+ if (path.deadline !== undefined) return;
541
+ path.deadline = setTimeout(() => {
542
+ if (state.path !== path) return;
543
+ if (path.kind === 'direct') {
544
+ state.avoid.fail(candidateKey(path.candidate), this.now(), this.recheckMs());
545
+ } else {
546
+ state.avoidCarriers.fail(path.carrier, this.now(), this.recheckMs());
547
+ }
548
+ this.reset(state);
549
+ this.fallBack(state);
550
+ }, this.options.admissionTimeoutMs ?? DEFAULT_ADMISSION_TIMEOUT_MS);
551
+ path.deadline.unref?.();
552
+ }
553
+
554
+ private useDirect(
555
+ state: IRouteState,
556
+ socket: IWebSocketLike,
557
+ candidate: IMeshCandidate,
558
+ ): Extract<TPath, { kind: 'direct' }> {
559
+ const path: Extract<TPath, { kind: 'direct' }> = { kind: 'direct', socket, candidate };
560
+ state.path = path;
561
+ const drop = (): void => {
562
+ if (state.path === path) this.reset(state);
563
+ };
564
+ socket.on('close', drop);
565
+ socket.on('error', drop);
566
+ // The endpoint no longer holds the pair's topic (the peer restarted, or it is someone else now).
567
+ socket.on('message', (raw) => {
568
+ const frame = parse(raw);
569
+ if (frame?.type === 'absent' && frame.to === lanAddressOf(state.lanOut)) drop();
570
+ });
571
+ return path;
572
+ }
573
+
574
+ private reset(state: IRouteState): void {
575
+ const path = state.path;
576
+ state.path = { kind: 'unresolved' };
577
+ if (path.kind === 'relay') clearTimeout(path.deadline);
578
+ if (path.kind === 'direct') {
579
+ clearTimeout(path.deadline);
580
+ path.socket.close();
581
+ }
582
+ }
583
+
584
+ public onMessage(handler: (topic: string, data: unknown) => void): () => void {
585
+ this.messages.add(handler);
586
+ return () => this.messages.delete(handler);
587
+ }
588
+
589
+ public onAbsent(handler: (topic: string) => void): () => void {
590
+ this.absents.add(handler);
591
+ return () => this.absents.delete(handler);
592
+ }
593
+
594
+ public close(): void {
595
+ if (this.closed) return;
596
+ this.closed = true;
597
+ this.abort.abort();
598
+ if (this.timer !== undefined) clearTimeout(this.timer);
599
+ for (const unsubscribe of this.unsubscribes.splice(0)) unsubscribe();
600
+ for (const state of this.routes.values()) this.reset(state);
601
+ this.routes.clear();
602
+ this.byDevice.clear();
603
+ this.messages.clear();
604
+ this.absents.clear();
605
+ this.waiting = [];
606
+ for (const advertiser of this.options.advertisers ?? []) advertiser.close();
607
+ // allow-fallback: closing the endpoint is best effort once this relay is done with it
608
+ void this.options.listener.close().catch(() => undefined);
609
+ // The carriers it was given stay the caller's to close, as a relay handed to a node does.
610
+ }
611
+ }
612
+
613
+ export interface IStartLanMeshRelayOptions {
614
+ /** The self-hosted relay: the last carrier. */
615
+ readonly relay?: IMeshRelay;
616
+ /**
617
+ * Beyond the local network: public records to find peers by (closed with the relay), and public
618
+ * signaling carriers tried before the self-hosted relay (the caller's to close).
619
+ */
620
+ readonly internet?: {
621
+ readonly dht?: MeshDht;
622
+ readonly signaling?: readonly IMeshRelay[];
623
+ };
624
+ /** Default: one that lives as long as the process. */
625
+ readonly cache?: IMeshAddressCache;
626
+ /** The address the direct signaling endpoint binds; default: every interface. */
627
+ readonly host?: string;
628
+ /** mDNS options, or `false` for none (the cache and the relay still work). */
629
+ readonly mdns?: IMeshMdnsOptions | false;
630
+ readonly onError?: (error: Error) => void;
631
+ }
632
+
633
+ /**
634
+ * A {@link DiscoveringMeshRelay} over this device's direct signaling endpoint, looking for peers in
635
+ * the address cache, with mDNS, then in public records, and signaling through the public carriers,
636
+ * then the self-hosted relay, when no candidate answers.
637
+ */
638
+ export async function startLanMeshRelay(
639
+ options: IStartLanMeshRelayOptions = {},
640
+ ): Promise<DiscoveringMeshRelay> {
641
+ const cache = options.cache ?? createInMemoryMeshAddressCache();
642
+ const lastPort = cache.lastListenPort();
643
+ const listener = await startMeshLanListener({
644
+ ...(lastPort !== undefined ? { port: lastPort } : {}),
645
+ ...(options.host !== undefined ? { host: options.host } : {}),
646
+ });
647
+ try {
648
+ cache.rememberListenPort(listener.port);
649
+ } catch (error) {
650
+ // allow-fallback: a port that cannot be remembered only means peers look this device up again
651
+ options.onError?.(error instanceof Error ? error : new Error(String(error)));
652
+ }
653
+ const mdns =
654
+ options.mdns === false
655
+ ? undefined
656
+ : new MeshMdns({
657
+ ...(options.onError !== undefined ? { onError: options.onError } : {}),
658
+ ...options.mdns,
659
+ });
660
+ const dht = options.internet?.dht;
661
+ const signaling = options.internet?.signaling ?? [];
662
+ return new DiscoveringMeshRelay({
663
+ ...(options.relay !== undefined ? { relay: options.relay } : {}),
664
+ ...(signaling.length > 0 ? { signaling } : {}),
665
+ listener,
666
+ sources: [
667
+ addressCacheSource(cache),
668
+ ...(mdns !== undefined ? [mdns] : []),
669
+ ...(dht !== undefined ? [dht] : []),
670
+ ],
671
+ cache,
672
+ advertisers: [...(mdns !== undefined ? [mdns] : []), ...(dht !== undefined ? [dht] : [])],
673
+ ...(options.onError !== undefined ? { onError: options.onError } : {}),
674
+ });
675
+ }