@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,446 @@
1
+ /**
2
+ * Finding a peer device beyond the local network through records on public infrastructure (the
3
+ * Mainline DHT, or pkarr relays in front of it), and handing on the newest device lists the same way.
4
+ *
5
+ * For every peer this device publishes, under `dir(self → peer)` and the current epoch:
6
+ * - its connection hints: the addresses of its direct signaling endpoint;
7
+ * - the newest device revocation lists it holds, so a peer can learn of a revocation from any other
8
+ * device of the pair set before it admits someone.
9
+ *
10
+ * Each record is signed by a one-time key of that pair, direction, epoch and purpose, stored under a
11
+ * rotating salt, and sealed by the pair; its plaintext is padded to a fixed size. Publish times are
12
+ * jittered per pair, so the records one device publishes do not appear together. A lookup reads
13
+ * `dir(peer → self)` at the current epoch and the adjacent ones.
14
+ *
15
+ * What a lookup yields is a candidate or an unverified list, never trust: candidates only carry
16
+ * signals, and lists are verified by the device handshake before anything uses them.
17
+ */
18
+ import {
19
+ rendezvousEpoch,
20
+ type IDeviceRevocationList,
21
+ type ISigningKeyRevocation,
22
+ } from '@robota-sdk/agent-remote-pairing';
23
+
24
+ import {
25
+ chunkCount,
26
+ chunkJson,
27
+ decodeHints,
28
+ decodeRelayHints,
29
+ encodeHints,
30
+ itemAddress,
31
+ joinChunks,
32
+ publishJitter,
33
+ } from './mesh-records.js';
34
+
35
+ import type { IMeshCandidate, IMeshCandidateSource, IMeshPeerRoute } from './mesh-discovery.js';
36
+ import type { IRendezvousItemStore } from './mesh-item-store.js';
37
+
38
+ /** Longest delay before a pair's records are published, by default. */
39
+ export const DEFAULT_MAX_PUBLISH_JITTER_MS = 90_000;
40
+ /** How long a DHT lookup may take, by default: iterative lookups take seconds. */
41
+ const DEFAULT_LOOKUP_TIMEOUT_MS = 6_000;
42
+ /** Peers whose list records a freshness lookup reads. */
43
+ const MAX_LIST_PEERS = 16;
44
+ /** Candidates of one list kind a freshness lookup returns. */
45
+ const MAX_LIST_CANDIDATES = 16;
46
+ /** Candidates of one list kind taken from one peer's records. */
47
+ const MAX_LIST_CANDIDATES_PER_PEER = 2;
48
+ /** How long "no relay" found for a peer holds before its records are looked up again. */
49
+ const NO_RELAY_RECHECK_MS = 2 * 60 * 1000;
50
+ /** How long a background lookup of the lists may take. */
51
+ const PREFETCH_TIMEOUT_MS = 30_000;
52
+
53
+ /** The device lists this device hands on to its peers. */
54
+ export interface IPublishedLists {
55
+ readonly revocation: IDeviceRevocationList;
56
+ readonly signingKeyRevocation?: ISigningKeyRevocation;
57
+ }
58
+
59
+ /** What a freshness lookup found, as received, newest first: each candidate is verified before use. */
60
+ export interface IFetchedLists {
61
+ readonly revocation?: readonly unknown[];
62
+ readonly signingKeyRevocation?: readonly unknown[];
63
+ }
64
+
65
+ export interface IMeshDhtOptions {
66
+ /** Where records go and come from; every one is written to and read from. */
67
+ readonly stores: readonly IRendezvousItemStore[];
68
+ /** This device's addresses to publish with its endpoint port. */
69
+ readonly addresses: () => readonly string[];
70
+ /** Where this device's relay listens, published to each paired device with its hints; none: no relay. */
71
+ readonly relayEndpoints?: () => readonly IMeshCandidate[];
72
+ /** The newest lists this device holds; absent or `undefined`: no list records are published. */
73
+ readonly lists?: () => IPublishedLists | undefined;
74
+ readonly maxPublishJitterMs?: number;
75
+ /** How long a candidate lookup may take. */
76
+ readonly lookupTimeoutMs?: number;
77
+ readonly random?: () => number;
78
+ readonly now?: () => number;
79
+ /** A record could not be published; lookups still run. */
80
+ readonly onError?: (error: Error) => void;
81
+ }
82
+
83
+ interface IScheduled {
84
+ readonly timer: ReturnType<typeof setTimeout>;
85
+ readonly epoch: number;
86
+ readonly port: number;
87
+ }
88
+
89
+ function seqOf(value: unknown): number {
90
+ if (typeof value !== 'object' || value === null) return -1;
91
+ const seq = (value as { seq?: unknown }).seq;
92
+ return typeof seq === 'number' && Number.isSafeInteger(seq) ? seq : -1;
93
+ }
94
+
95
+ function asError(error: unknown): Error {
96
+ return error instanceof Error ? error : new Error(String(error));
97
+ }
98
+
99
+ export class MeshDht implements IMeshCandidateSource {
100
+ public readonly timeoutMs: number;
101
+ private readonly scheduled = new Map<string, IScheduled>();
102
+ private routes: readonly IMeshPeerRoute[] = [];
103
+ /** The lists found by the last lookup that finished, for a lookup that runs out of time. */
104
+ private listsFound: IFetchedLists | undefined;
105
+ private prefetchedEpoch?: number;
106
+ /** The relays peers advertised, by device: the epoch and time they were looked up. */
107
+ private readonly relaysFound = new Map<
108
+ string,
109
+ {
110
+ readonly epoch: number;
111
+ readonly checkedAt: number;
112
+ readonly endpoints: readonly IMeshCandidate[];
113
+ }
114
+ >();
115
+ /** Relay lookups running, by device. */
116
+ private readonly relayLookups = new Map<string, Promise<void>>();
117
+ private closed = false;
118
+
119
+ public constructor(private readonly options: IMeshDhtOptions) {
120
+ this.timeoutMs = options.lookupTimeoutMs ?? DEFAULT_LOOKUP_TIMEOUT_MS;
121
+ }
122
+
123
+ private now(): number {
124
+ return (this.options.now ?? Date.now)();
125
+ }
126
+
127
+ /** The peer's endpoint addresses from its hints record, at this epoch or an adjacent one. */
128
+ public async candidates(
129
+ peer: IMeshPeerRoute,
130
+ signal: AbortSignal,
131
+ ): Promise<readonly IMeshCandidate[]> {
132
+ const epoch = rendezvousEpoch(this.now());
133
+ const found = await this.lookup(peer, 'hints', epoch, signal);
134
+ // A lookup cut short says nothing about a relay.
135
+ if (!signal.aborted) this.rememberRelays(peer, epoch, found);
136
+ const out: IMeshCandidate[] = [];
137
+ const seen = new Set<string>();
138
+ // Newest epoch first: its addresses are the likeliest to be current.
139
+ for (const record of found.sort((a, b) => b.epoch - a.epoch)) {
140
+ for (const candidate of decodeHints(record.data)) {
141
+ const key = `${candidate.host}:${candidate.port}`;
142
+ if (seen.has(key)) continue;
143
+ seen.add(key);
144
+ out.push(candidate);
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** The relay endpoints in the newest of `found`, the peer's hints records. */
151
+ private rememberRelays(
152
+ peer: IMeshPeerRoute,
153
+ epoch: number,
154
+ found: readonly { readonly epoch: number; readonly data: Uint8Array }[],
155
+ ): void {
156
+ const newest = [...found].sort((a, b) => b.epoch - a.epoch)[0];
157
+ const endpoints = newest === undefined ? [] : decodeRelayHints(newest.data);
158
+ this.relaysFound.set(peer.deviceId, { epoch, checkedAt: this.now(), endpoints });
159
+ }
160
+
161
+ /**
162
+ * The relays `peers` advertise to this device, by device id. What a lookup found holds for the
163
+ * epoch, and "no relay" for a short while, so connection attempts do not wait on lookups; a
164
+ * lookup still running when `signal` ends goes on, and the next call has its answer. Only a
165
+ * peer's own sealed hints record says where its relay is, so no one else learns it and no one
166
+ * else can plant one.
167
+ */
168
+ public async relayAdverts(
169
+ peers: readonly IMeshPeerRoute[],
170
+ signal: AbortSignal,
171
+ ): Promise<ReadonlyMap<string, readonly IMeshCandidate[]>> {
172
+ const now = this.now();
173
+ const epoch = rendezvousEpoch(now);
174
+ const routes = peers.slice(0, MAX_LIST_PEERS);
175
+ const running: Promise<void>[] = [];
176
+ for (const route of routes) {
177
+ const known = this.relaysFound.get(route.deviceId);
178
+ const fresh =
179
+ known !== undefined &&
180
+ known.epoch === epoch &&
181
+ (known.endpoints.length > 0 || now - known.checkedAt < NO_RELAY_RECHECK_MS);
182
+ if (fresh) continue;
183
+ running.push(this.lookUpRelay(route, epoch));
184
+ }
185
+ if (running.length > 0) {
186
+ const ended = new Promise<void>((resolve) => {
187
+ if (signal.aborted) resolve();
188
+ signal.addEventListener('abort', () => resolve(), { once: true });
189
+ });
190
+ await Promise.race([Promise.all(running), ended]);
191
+ }
192
+ const current = new Set(peers.map((route) => route.deviceId));
193
+ for (const deviceId of [...this.relaysFound.keys()]) {
194
+ if (!current.has(deviceId)) this.relaysFound.delete(deviceId);
195
+ }
196
+ const out = new Map<string, readonly IMeshCandidate[]>();
197
+ for (const route of routes) {
198
+ const endpoints = this.relaysFound.get(route.deviceId)?.endpoints ?? [];
199
+ if (endpoints.length > 0) out.set(route.deviceId, endpoints);
200
+ }
201
+ return out;
202
+ }
203
+
204
+ /** One lookup of a peer's relay at a time, bounded by the lookup timeout, not by any caller. */
205
+ private lookUpRelay(route: IMeshPeerRoute, epoch: number): Promise<void> {
206
+ const held = this.relayLookups.get(route.deviceId);
207
+ if (held !== undefined) return held;
208
+ const signal = AbortSignal.timeout(this.timeoutMs);
209
+ const running = this.lookup(route, 'hints', epoch, signal)
210
+ .then((found) => {
211
+ if (!signal.aborted && !this.closed) this.rememberRelays(route, epoch, found);
212
+ })
213
+ .finally(() => this.relayLookups.delete(route.deviceId));
214
+ this.relayLookups.set(route.deviceId, running);
215
+ return running;
216
+ }
217
+
218
+ /** The records of `purpose` the peer published for this device, opened; empty when none. */
219
+ private async lookup(
220
+ peer: IMeshPeerRoute,
221
+ purpose: 'hints' | 'revocation',
222
+ epoch: number,
223
+ signal: AbortSignal,
224
+ ): Promise<{ readonly epoch: number; readonly data: Uint8Array }[]> {
225
+ const reads = [epoch - 1, epoch, epoch + 1].flatMap((e) =>
226
+ this.options.stores.map(async (store) => {
227
+ const data = await this.read(store, peer, purpose, e, 0, signal);
228
+ return data === undefined ? undefined : { epoch: e, data };
229
+ }),
230
+ );
231
+ return (await Promise.all(reads)).filter((r) => r !== undefined);
232
+ }
233
+
234
+ /** One chunk of the peer's record, opened; `undefined` when it is not there or not the peer's. */
235
+ private async read(
236
+ store: IRendezvousItemStore,
237
+ peer: IMeshPeerRoute,
238
+ purpose: 'hints' | 'revocation',
239
+ epoch: number,
240
+ chunk: number,
241
+ signal: AbortSignal,
242
+ ): Promise<Uint8Array | undefined> {
243
+ try {
244
+ const address = await itemAddress(peer.rendezvous, purpose, 'inbound', epoch, chunk);
245
+ const value = await store.get(address.key.publicKey, address.salt, signal);
246
+ if (value === undefined) return undefined;
247
+ // Sealed for exactly this pair, direction, purpose and epoch, or it is not the peer's.
248
+ const opened = await peer.rendezvous.openRecord(value, epoch, purpose);
249
+ return opened?.epoch === epoch ? opened.hints : undefined;
250
+ } catch {
251
+ // allow-fallback: a record that cannot be read is not there; the other stores and epochs count
252
+ return undefined;
253
+ }
254
+ }
255
+
256
+ /** The device lists the peer published for this device, whole; one per store and epoch found. */
257
+ private async lookupLists(
258
+ peer: IMeshPeerRoute,
259
+ epoch: number,
260
+ signal: AbortSignal,
261
+ ): Promise<unknown[]> {
262
+ const reads = [epoch - 1, epoch, epoch + 1].flatMap((e) =>
263
+ this.options.stores.map(async (store) => {
264
+ const first = await this.read(store, peer, 'revocation', e, 0, signal);
265
+ const count = first === undefined ? undefined : chunkCount(first);
266
+ if (first === undefined || count === undefined) return undefined;
267
+ const rest = await Promise.all(
268
+ Array.from({ length: count - 1 }, (_, i) =>
269
+ this.read(store, peer, 'revocation', e, i + 1, signal),
270
+ ),
271
+ );
272
+ if (rest.some((chunk) => chunk === undefined)) return undefined;
273
+ return joinChunks([first, ...(rest as Uint8Array[])]);
274
+ }),
275
+ );
276
+ return (await Promise.all(reads)).filter((value) => value !== undefined);
277
+ }
278
+
279
+ /**
280
+ * Publish this device's records for `routes`, each pair after its own random delay. A pair already
281
+ * waiting to publish for this epoch and port keeps its delay.
282
+ */
283
+ public advertise(routes: readonly IMeshPeerRoute[], port: number): Promise<void> {
284
+ if (this.closed) return Promise.resolve();
285
+ this.routes = routes;
286
+ const epoch = rendezvousEpoch(this.now());
287
+ const wanted = new Set(routes.map((r) => r.outbound));
288
+ for (const [topic, scheduled] of this.scheduled) {
289
+ if (!wanted.has(topic)) {
290
+ clearTimeout(scheduled.timer);
291
+ this.scheduled.delete(topic);
292
+ }
293
+ }
294
+ if (this.prefetchedEpoch !== epoch) {
295
+ // The freshness lookup has only seconds; what the peers published is read ahead of it.
296
+ this.prefetchedEpoch = epoch;
297
+ void this.findLists(AbortSignal.timeout(PREFETCH_TIMEOUT_MS));
298
+ }
299
+ const maxJitter = this.options.maxPublishJitterMs ?? DEFAULT_MAX_PUBLISH_JITTER_MS;
300
+ for (const route of routes) {
301
+ const held = this.scheduled.get(route.outbound);
302
+ if (held !== undefined && held.epoch === epoch && held.port === port) continue;
303
+ if (held !== undefined) clearTimeout(held.timer);
304
+ const timer = setTimeout(
305
+ () => {
306
+ this.scheduled.delete(route.outbound);
307
+ void this.publish(route, port);
308
+ },
309
+ publishJitter(maxJitter, this.options.random),
310
+ );
311
+ timer.unref?.();
312
+ this.scheduled.set(route.outbound, { timer, epoch, port });
313
+ }
314
+ return Promise.resolve();
315
+ }
316
+
317
+ private async publish(route: IMeshPeerRoute, port: number): Promise<void> {
318
+ if (this.closed) return;
319
+ const now = this.now();
320
+ const epoch = rendezvousEpoch(now);
321
+ const records: { purpose: 'hints' | 'revocation'; chunk: number; plaintext: Uint8Array }[] = [];
322
+ try {
323
+ records.push({
324
+ purpose: 'hints',
325
+ chunk: 0,
326
+ plaintext: encodeHints(
327
+ this.options.addresses().map((host) => ({ host, port })),
328
+ this.options.relayEndpoints?.() ?? [],
329
+ ),
330
+ });
331
+ const lists = this.options.lists?.();
332
+ if (lists !== undefined) {
333
+ const chunks = await chunkJson({
334
+ r: lists.revocation,
335
+ ...(lists.signingKeyRevocation !== undefined ? { s: lists.signingKeyRevocation } : {}),
336
+ });
337
+ for (const [chunk, plaintext] of chunks.entries()) {
338
+ records.push({ purpose: 'revocation', chunk, plaintext });
339
+ }
340
+ }
341
+ } catch (error) {
342
+ this.options.onError?.(asError(error));
343
+ }
344
+ for (const { purpose, chunk, plaintext } of records) {
345
+ try {
346
+ const sealed = await route.rendezvous.sealRecord(plaintext, epoch, purpose);
347
+ const address = await itemAddress(route.rendezvous, purpose, 'outbound', epoch, chunk);
348
+ const results = await Promise.allSettled(
349
+ this.options.stores.map((store) => store.put(address, sealed, now)),
350
+ );
351
+ for (const result of results) {
352
+ if (result.status === 'rejected') this.options.onError?.(asError(result.reason));
353
+ }
354
+ } catch (error) {
355
+ this.options.onError?.(asError(error));
356
+ }
357
+ }
358
+ }
359
+
360
+ /**
361
+ * The lists the peers published for this device, as received — candidates for the freshness
362
+ * lookup before a remote admission, each verified there. Any paired device can publish anything
363
+ * here, so candidates are returned rather than the one that claims to be newest, and each peer's
364
+ * newest few come before any peer's next, so one peer's records cannot crowd out another's. When
365
+ * `signal` ends first, what the last finished lookup found. `undefined`: none.
366
+ */
367
+ public async latestLists(signal: AbortSignal): Promise<IFetchedLists | undefined> {
368
+ const ended = new Promise<undefined>((resolve) => {
369
+ if (signal.aborted) resolve(undefined);
370
+ signal.addEventListener('abort', () => resolve(undefined), { once: true });
371
+ });
372
+ const found = await Promise.race([this.findLists(signal), ended]);
373
+ return found ?? this.listsFound;
374
+ }
375
+
376
+ private async findLists(signal: AbortSignal): Promise<IFetchedLists | undefined> {
377
+ const epoch = rendezvousEpoch(this.now());
378
+ const found = await Promise.all(
379
+ this.routes.slice(0, MAX_LIST_PEERS).map((route) => this.lookupLists(route, epoch, signal)),
380
+ );
381
+ const revocation: unknown[][] = [];
382
+ const signingKeyRevocation: unknown[][] = [];
383
+ for (const values of found) {
384
+ const r: unknown[] = [];
385
+ const s: unknown[] = [];
386
+ for (const value of values) {
387
+ if (typeof value !== 'object' || value === null) continue;
388
+ const lists = value as { r?: unknown; s?: unknown };
389
+ if (lists.r !== undefined) r.push(lists.r);
390
+ if (lists.s !== undefined) s.push(lists.s);
391
+ }
392
+ revocation.push(r);
393
+ signingKeyRevocation.push(s);
394
+ }
395
+ const r = fairlyNewestFirst(revocation);
396
+ const s = fairlyNewestFirst(signingKeyRevocation);
397
+ const lists: IFetchedLists | undefined =
398
+ r.length === 0 && s.length === 0
399
+ ? undefined
400
+ : {
401
+ ...(r.length > 0 ? { revocation: r } : {}),
402
+ ...(s.length > 0 ? { signingKeyRevocation: s } : {}),
403
+ };
404
+ if (!signal.aborted) this.listsFound = lists;
405
+ return lists;
406
+ }
407
+
408
+ public close(): void {
409
+ if (this.closed) return;
410
+ this.closed = true;
411
+ for (const { timer } of this.scheduled.values()) clearTimeout(timer);
412
+ this.scheduled.clear();
413
+ for (const store of this.options.stores) store.close();
414
+ }
415
+ }
416
+
417
+ /** Candidates without repeats, in order. */
418
+ function distinct(candidates: readonly unknown[]): unknown[] {
419
+ const seen = new Set<string>();
420
+ return candidates.filter((c) => {
421
+ const key = JSON.stringify(c);
422
+ if (seen.has(key)) return false;
423
+ seen.add(key);
424
+ return true;
425
+ });
426
+ }
427
+
428
+ /** Distinct candidates, the one claiming the highest `seq` first. */
429
+ function newestFirst(candidates: readonly unknown[]): unknown[] {
430
+ return distinct(candidates).sort((a, b) => seqOf(b) - seqOf(a));
431
+ }
432
+
433
+ /**
434
+ * Distinct candidates from every peer, bounded: a few per peer, and every peer's newest before any
435
+ * peer's next, so the bound never cuts one peer's newest for another's many.
436
+ */
437
+ function fairlyNewestFirst(byPeer: readonly (readonly unknown[])[]): unknown[] {
438
+ const ranked = byPeer.map((candidates) =>
439
+ newestFirst(candidates).slice(0, MAX_LIST_CANDIDATES_PER_PEER),
440
+ );
441
+ const rounds: unknown[] = [];
442
+ for (let rank = 0; rank < MAX_LIST_CANDIDATES_PER_PEER; rank += 1) {
443
+ rounds.push(...newestFirst(ranked.flatMap((candidates) => candidates.slice(rank, rank + 1))));
444
+ }
445
+ return distinct(rounds).slice(0, MAX_LIST_CANDIDATES);
446
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Where a peer device might be reached for signaling, and where that answer comes from.
3
+ *
4
+ * A candidate is only an address to try. Whatever answers there is untrusted like any relay: it
5
+ * carries signals, and the peer is still admitted by the device handshake or not at all. So a
6
+ * candidate may be stale, forged or someone else's without granting anything.
7
+ */
8
+ import type { IPairRendezvous } from '@robota-sdk/agent-remote-pairing';
9
+
10
+ /** A direct signaling endpoint a peer might be listening on. */
11
+ export interface IMeshCandidate {
12
+ readonly host: string;
13
+ readonly port: number;
14
+ }
15
+
16
+ /** A peer device a node signals, with the pair's rendezvous and relay inbox topics. */
17
+ export interface IMeshPeerRoute {
18
+ readonly deviceId: string;
19
+ /** Where this device listens for the peer on a relay. */
20
+ readonly inbound: string;
21
+ /** Where this device sends to reach the peer on a relay. */
22
+ readonly outbound: string;
23
+ readonly rendezvous: IPairRendezvous;
24
+ }
25
+
26
+ /** One way of finding a peer's direct signaling endpoint. */
27
+ export interface IMeshCandidateSource {
28
+ /** How long a lookup of this source may take; default: the caller's. */
29
+ readonly timeoutMs?: number;
30
+ /** Candidates for `peer`, best first; empty when this source knows none. Never throws. */
31
+ candidates(peer: IMeshPeerRoute, signal: AbortSignal): Promise<readonly IMeshCandidate[]>;
32
+ }
33
+
34
+ /** The last candidates that worked for each peer device, most recent first. */
35
+ export interface IMeshAddressCache {
36
+ recall(deviceId: string): readonly IMeshCandidate[];
37
+ remember(deviceId: string, candidate: IMeshCandidate): void;
38
+ /** Forget every device but these: a device that left the lists has no address worth keeping. */
39
+ retain(deviceIds: readonly string[]): void;
40
+ /** The port this device listened on last, to listen on again so peers' caches stay useful. */
41
+ lastListenPort(): number | undefined;
42
+ rememberListenPort(port: number): void;
43
+ }
44
+
45
+ /** Candidates per device the cache keeps. */
46
+ export const MAX_CACHED_CANDIDATES = 3;
47
+
48
+ /** The cache, as the first candidate source. */
49
+ export function addressCacheSource(cache: IMeshAddressCache): IMeshCandidateSource {
50
+ return { candidates: (peer) => Promise.resolve(cache.recall(peer.deviceId)) };
51
+ }
52
+
53
+ function sameCandidate(a: IMeshCandidate, b: IMeshCandidate): boolean {
54
+ return a.host === b.host && a.port === b.port;
55
+ }
56
+
57
+ /** `candidate` first, then the others it does not repeat, bounded. */
58
+ function withCandidateFirst(
59
+ candidates: readonly IMeshCandidate[],
60
+ candidate: IMeshCandidate,
61
+ ): IMeshCandidate[] {
62
+ return [
63
+ { host: candidate.host, port: candidate.port },
64
+ ...candidates.filter((c) => !sameCandidate(c, candidate)),
65
+ ].slice(0, MAX_CACHED_CANDIDATES);
66
+ }
67
+
68
+ /** An address cache that lives as long as the process — for tests and callers without a state dir. */
69
+ export function createInMemoryMeshAddressCache(): IMeshAddressCache {
70
+ const byDevice = new Map<string, IMeshCandidate[]>();
71
+ let listenPort: number | undefined;
72
+ return {
73
+ recall: (deviceId) => byDevice.get(deviceId) ?? [],
74
+ remember: (deviceId, candidate) => {
75
+ byDevice.set(deviceId, withCandidateFirst(byDevice.get(deviceId) ?? [], candidate));
76
+ },
77
+ retain: (deviceIds) => {
78
+ const keep = new Set(deviceIds);
79
+ for (const deviceId of [...byDevice.keys()])
80
+ if (!keep.has(deviceId)) byDevice.delete(deviceId);
81
+ },
82
+ lastListenPort: () => listenPort,
83
+ rememberListenPort: (port) => {
84
+ listenPort = port;
85
+ },
86
+ };
87
+ }