@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,406 @@
1
+ /**
2
+ * Finding peer devices on the local network with multicast DNS service discovery (RFC 6762/6763),
3
+ * without telling the network whose devices they are or how many.
4
+ *
5
+ * - The service type is generic and names no product.
6
+ * - Every instance name is a pairwise tag for `dir(self → peer)` in the current epoch, so only that
7
+ * peer recognises it, and it changes every epoch. The host name the records point at is random per
8
+ * epoch rather than the machine's name.
9
+ * - The instance count is padded with random tags to a fixed step, so the answer does not reveal
10
+ * how many devices this one pairs with. The padding and host name hold for the whole epoch, so
11
+ * comparing two answers from one epoch does not tell the real tags from the padding either.
12
+ *
13
+ * A lookup asks for the service, keeps the instances whose name is one of the peer's tags for the
14
+ * adjacent epochs, and yields the answering address with the advertised port. That is a candidate
15
+ * only: anyone on the network can answer too, and gains nothing, because the peer is admitted by the
16
+ * device handshake or not at all. Since anyone can answer, a lookup does not stop at the first match:
17
+ * it keeps listening a little longer, and takes only a few answers from any one address, so neither
18
+ * an early answer nor many answers from one host can stand in for the peer's own.
19
+ */
20
+ import { createRequire } from 'node:module';
21
+ import { networkInterfaces } from 'node:os';
22
+
23
+ import { rendezvousEpoch, RENDEZVOUS_EPOCH_MS } from '@robota-sdk/agent-remote-pairing';
24
+
25
+ import type { IMeshCandidate, IMeshCandidateSource, IMeshPeerRoute } from './mesh-discovery.js';
26
+
27
+ /** The DNS-SD service type. Generic on purpose: it must not name the product. */
28
+ export const MESH_MDNS_SERVICE = '_rdv._tcp.local';
29
+ /** Instance counts are padded up to a multiple of this. */
30
+ export const MESH_MDNS_PAD_STEP = 8;
31
+ /** Bytes of a pairwise tag used as an instance name (hex-encoded). */
32
+ const INSTANCE_TAG_BYTES = 16;
33
+ const RECORD_TTL_S = 120;
34
+ const DEFAULT_LOOKUP_TIMEOUT_MS = 1_000;
35
+ /** After the first match, how much longer a lookup listens for more answers. */
36
+ const DEFAULT_ANSWER_GRACE_MS = 200;
37
+ /** Candidates one lookup yields. */
38
+ const MAX_LOOKUP_CANDIDATES = 8;
39
+ /** Candidates one lookup takes from one answering address. */
40
+ const MAX_CANDIDATES_PER_ADDRESS = 2;
41
+ /** Answers go out at most this often; queries in between share the next one (RFC 6762 §6). */
42
+ const DEFAULT_MIN_ANSWER_INTERVAL_MS = 500;
43
+
44
+ /** A DNS resource record, in the shape `multicast-dns` reads and writes. */
45
+ export interface IMdnsRecord {
46
+ readonly name: string;
47
+ readonly type: string;
48
+ readonly ttl?: number;
49
+ readonly data?: unknown;
50
+ }
51
+
52
+ export interface IMdnsQuestion {
53
+ readonly name: string;
54
+ readonly type: string;
55
+ }
56
+
57
+ export interface IMdnsPacket {
58
+ readonly questions?: readonly IMdnsQuestion[];
59
+ readonly answers?: readonly IMdnsRecord[];
60
+ readonly additionals?: readonly IMdnsRecord[];
61
+ }
62
+
63
+ export interface IMdnsRemote {
64
+ readonly address: string;
65
+ }
66
+
67
+ /** The slice of a `multicast-dns` instance this module drives. */
68
+ export interface IMdnsTransport {
69
+ on(
70
+ event: 'query' | 'response',
71
+ listener: (packet: IMdnsPacket, rinfo: IMdnsRemote) => void,
72
+ ): void;
73
+ on(event: 'error' | 'warning', listener: (error: Error) => void): void;
74
+ removeListener(
75
+ event: 'response',
76
+ listener: (packet: IMdnsPacket, rinfo: IMdnsRemote) => void,
77
+ ): void;
78
+ query(packet: { readonly questions: readonly IMdnsQuestion[] }): void;
79
+ respond(packet: {
80
+ readonly answers: readonly IMdnsRecord[];
81
+ readonly additionals?: readonly IMdnsRecord[];
82
+ }): void;
83
+ destroy(): void;
84
+ }
85
+
86
+ export interface IMeshMdnsOptions {
87
+ /** Default: a `multicast-dns` socket on the standard group and port. */
88
+ readonly createTransport?: () => IMdnsTransport;
89
+ /** This device's addresses for the A/AAAA records. Default: its non-internal interfaces. */
90
+ readonly addresses?: () => readonly string[];
91
+ readonly lookupTimeoutMs?: number;
92
+ /** After the first match, how much longer a lookup listens (within `lookupTimeoutMs`). */
93
+ readonly answerGraceMs?: number;
94
+ readonly minAnswerIntervalMs?: number;
95
+ /** mDNS cannot run (e.g. the port is unavailable): the other candidate sources still do. */
96
+ readonly onError?: (error: Error) => void;
97
+ readonly now?: () => number;
98
+ }
99
+
100
+ function defaultTransport(): IMdnsTransport {
101
+ const requireFrom = createRequire(import.meta.url);
102
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- resolved only when mDNS is used, so importing the package opens no socket
103
+ const create = requireFrom('multicast-dns') as () => IMdnsTransport;
104
+ return create();
105
+ }
106
+
107
+ /** This device's non-internal interface addresses (no IPv6 link-local). */
108
+ export function localInterfaceAddresses(): string[] {
109
+ const out: string[] = [];
110
+ for (const entries of Object.values(networkInterfaces())) {
111
+ for (const entry of entries ?? []) {
112
+ if (entry.internal) continue;
113
+ if (
114
+ entry.family === 'IPv4' ||
115
+ (entry.family === 'IPv6' && !entry.address.startsWith('fe80'))
116
+ ) {
117
+ out.push(entry.address);
118
+ }
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function randomHex(bytes: number): string {
125
+ const out = new Uint8Array(bytes);
126
+ globalThis.crypto.getRandomValues(out);
127
+ return Buffer.from(out).toString('hex');
128
+ }
129
+
130
+ function instanceLabel(tag: Uint8Array): string {
131
+ return Buffer.from(tag.subarray(0, INSTANCE_TAG_BYTES)).toString('hex');
132
+ }
133
+
134
+ /** How many instances `count` real ones are announced as. */
135
+ export function paddedInstanceCount(count: number): number {
136
+ return Math.max(MESH_MDNS_PAD_STEP, Math.ceil(count / MESH_MDNS_PAD_STEP) * MESH_MDNS_PAD_STEP);
137
+ }
138
+
139
+ function sameName(a: string, b: string): boolean {
140
+ return a.toLowerCase() === b.toLowerCase();
141
+ }
142
+
143
+ export class MeshMdns implements IMeshCandidateSource {
144
+ private transport?: IMdnsTransport;
145
+ private routes: readonly IMeshPeerRoute[] = [];
146
+ private port?: number;
147
+ private records?: { readonly answers: IMdnsRecord[]; readonly additionals: IMdnsRecord[] };
148
+ private instances = new Set<string>();
149
+ private timer?: ReturnType<typeof setTimeout>;
150
+ private generation = 0;
151
+ private closed = false;
152
+ /** This epoch's padding labels and host name. */
153
+ private epochCover?: {
154
+ readonly epoch: number;
155
+ readonly padding: string[];
156
+ readonly target: string;
157
+ };
158
+ /** What was last announced, to announce again only when it changes. */
159
+ private announced?: string;
160
+ private lastAnswer?: number;
161
+ private answerTimer?: ReturnType<typeof setTimeout>;
162
+
163
+ public constructor(private readonly options: IMeshMdnsOptions = {}) {}
164
+
165
+ private now(): number {
166
+ return (this.options.now ?? Date.now)();
167
+ }
168
+
169
+ /** The transport, created on first use. `undefined` once it failed or the source is closed. */
170
+ private open(): IMdnsTransport | undefined {
171
+ if (this.closed) return undefined;
172
+ if (this.transport !== undefined) return this.transport;
173
+ try {
174
+ const transport = (this.options.createTransport ?? defaultTransport)();
175
+ transport.on('query', (packet: IMdnsPacket) => this.onQuery(packet));
176
+ transport.on('error', (error: Error) => this.options.onError?.(error));
177
+ this.transport = transport;
178
+ return transport;
179
+ } catch (error) {
180
+ this.closed = true;
181
+ this.options.onError?.(error instanceof Error ? error : new Error(String(error)));
182
+ return undefined;
183
+ }
184
+ }
185
+
186
+ /** Announce this device to `routes`' peers, as a signaling endpoint on `port`. */
187
+ public advertise(routes: readonly IMeshPeerRoute[], port: number): Promise<void> {
188
+ this.routes = routes;
189
+ this.port = port;
190
+ return this.rebuild();
191
+ }
192
+
193
+ private async rebuild(): Promise<void> {
194
+ const generation = ++this.generation;
195
+ const port = this.port;
196
+ if (this.closed || port === undefined) return;
197
+ const epoch = rendezvousEpoch(this.now());
198
+ const tags = await Promise.all(
199
+ this.routes.map((route) => route.rendezvous.tag('mdns', 'outbound', epoch)),
200
+ );
201
+ if (generation !== this.generation || this.closed) return;
202
+ if (this.epochCover?.epoch !== epoch) {
203
+ this.epochCover = { epoch, padding: [], target: `${randomHex(8)}.local` };
204
+ }
205
+ const { padding, target } = this.epochCover;
206
+ const needed = paddedInstanceCount(tags.length) - tags.length;
207
+ while (padding.length < needed) padding.push(randomHex(INSTANCE_TAG_BYTES));
208
+ const labels = [...tags.map(instanceLabel), ...padding.slice(0, needed)].sort();
209
+ const signature = JSON.stringify([epoch, port, labels]);
210
+ const answers: IMdnsRecord[] = labels.map((label) => ({
211
+ name: MESH_MDNS_SERVICE,
212
+ type: 'PTR',
213
+ ttl: RECORD_TTL_S,
214
+ data: `${label}.${MESH_MDNS_SERVICE}`,
215
+ }));
216
+ const additionals: IMdnsRecord[] = [];
217
+ for (const label of labels) {
218
+ const name = `${label}.${MESH_MDNS_SERVICE}`;
219
+ additionals.push(
220
+ { name, type: 'SRV', ttl: RECORD_TTL_S, data: { priority: 0, weight: 0, port, target } },
221
+ { name, type: 'TXT', ttl: RECORD_TTL_S, data: [] },
222
+ );
223
+ }
224
+ for (const address of (this.options.addresses ?? localInterfaceAddresses)()) {
225
+ additionals.push({
226
+ name: target,
227
+ type: address.includes(':') ? 'AAAA' : 'A',
228
+ ttl: RECORD_TTL_S,
229
+ data: address,
230
+ });
231
+ }
232
+ this.records = { answers, additionals };
233
+ this.instances = new Set(labels.map((label) => `${label}.${MESH_MDNS_SERVICE}`.toLowerCase()));
234
+ this.schedule(epoch);
235
+ if (signature === this.announced) return;
236
+ this.announced = signature;
237
+ // An unsolicited announcement, so peers already browsing hear of the new names at once.
238
+ this.open()?.respond(this.records);
239
+ }
240
+
241
+ private schedule(epoch: number): void {
242
+ if (this.timer !== undefined) clearTimeout(this.timer);
243
+ const delay = Math.max(0, (epoch + 1) * RENDEZVOUS_EPOCH_MS - this.now());
244
+ this.timer = setTimeout(() => {
245
+ // allow-fallback: a failed rebuild keeps the last epoch's names; lookups try adjacent epochs
246
+ this.rebuild().catch(() => undefined);
247
+ }, delay);
248
+ this.timer.unref?.();
249
+ }
250
+
251
+ private onQuery(packet: IMdnsPacket): void {
252
+ const records = this.records;
253
+ if (records === undefined || this.closed) return;
254
+ const asked = (packet.questions ?? []).some(
255
+ (q) =>
256
+ (sameName(q.name, MESH_MDNS_SERVICE) && (q.type === 'PTR' || q.type === 'ANY')) ||
257
+ this.instances.has(q.name.toLowerCase()),
258
+ );
259
+ if (asked) this.answer();
260
+ }
261
+
262
+ /** Answer now, or once the interval since the last answer has passed; queries meanwhile share it. */
263
+ private answer(): void {
264
+ if (this.answerTimer !== undefined) return;
265
+ const interval = this.options.minAnswerIntervalMs ?? DEFAULT_MIN_ANSWER_INTERVAL_MS;
266
+ const wait = this.lastAnswer === undefined ? 0 : this.lastAnswer + interval - this.now();
267
+ const send = (): void => {
268
+ this.answerTimer = undefined;
269
+ if (this.closed || this.records === undefined) return;
270
+ this.lastAnswer = this.now();
271
+ this.transport?.respond(this.records);
272
+ };
273
+ if (wait <= 0) {
274
+ send();
275
+ return;
276
+ }
277
+ this.answerTimer = setTimeout(send, wait);
278
+ this.answerTimer.unref?.();
279
+ }
280
+
281
+ public async candidates(
282
+ peer: IMeshPeerRoute,
283
+ signal: AbortSignal,
284
+ ): Promise<readonly IMeshCandidate[]> {
285
+ const transport = this.open();
286
+ if (transport === undefined || signal.aborted) return [];
287
+ const epoch = rendezvousEpoch(this.now());
288
+ const wanted = new Set(
289
+ (await peer.rendezvous.lookupTags('mdns', epoch)).map(
290
+ (tag) => `${instanceLabel(tag)}.${MESH_MDNS_SERVICE}`,
291
+ ),
292
+ );
293
+ if (signal.aborted || this.closed) return [];
294
+ return new Promise<readonly IMeshCandidate[]>((resolve) => {
295
+ const found: IMeshCandidate[] = [];
296
+ let grace: ReturnType<typeof setTimeout> | undefined;
297
+ const finish = (): void => {
298
+ clearTimeout(timer);
299
+ clearTimeout(grace);
300
+ signal.removeEventListener('abort', finish);
301
+ transport.removeListener('response', onResponse);
302
+ resolve(found);
303
+ };
304
+ const onResponse = (packet: IMdnsPacket, rinfo: IMdnsRemote): void => {
305
+ const records = [...(packet.answers ?? []), ...(packet.additionals ?? [])];
306
+ for (const ptr of records) {
307
+ if (ptr.type !== 'PTR' || !sameName(ptr.name, MESH_MDNS_SERVICE)) continue;
308
+ if (typeof ptr.data !== 'string' || !wanted.has(ptr.data.toLowerCase())) continue;
309
+ const srv = records.find((r) => r.type === 'SRV' && sameName(r.name, ptr.data as string));
310
+ const port = (srv?.data as { port?: unknown } | undefined)?.port;
311
+ if (typeof port !== 'number' || !Number.isInteger(port) || port <= 0 || port > 65_535) {
312
+ continue;
313
+ }
314
+ // The address the answer came from: the peer's own records could name any address.
315
+ // No one address takes more than a few of the places, so one host cannot fill them all.
316
+ const fromAddress = found.filter((c) => c.host === rinfo.address);
317
+ if (
318
+ found.length < MAX_LOOKUP_CANDIDATES &&
319
+ fromAddress.length < MAX_CANDIDATES_PER_ADDRESS &&
320
+ !fromAddress.some((c) => c.port === port)
321
+ ) {
322
+ found.push({ host: rinfo.address, port });
323
+ }
324
+ }
325
+ if (found.length === 0 || grace !== undefined) return;
326
+ grace = setTimeout(finish, this.options.answerGraceMs ?? DEFAULT_ANSWER_GRACE_MS);
327
+ grace.unref?.();
328
+ };
329
+ const timer = setTimeout(finish, this.options.lookupTimeoutMs ?? DEFAULT_LOOKUP_TIMEOUT_MS);
330
+ timer.unref?.();
331
+ signal.addEventListener('abort', finish, { once: true });
332
+ transport.on('response', onResponse);
333
+ transport.query({ questions: [{ name: MESH_MDNS_SERVICE, type: 'PTR' }] });
334
+ });
335
+ }
336
+
337
+ public close(): void {
338
+ this.closed = true;
339
+ this.generation += 1;
340
+ if (this.timer !== undefined) clearTimeout(this.timer);
341
+ if (this.answerTimer !== undefined) clearTimeout(this.answerTimer);
342
+ this.transport?.destroy();
343
+ this.transport = undefined;
344
+ }
345
+ }
346
+
347
+ export interface IInMemoryMdnsBus {
348
+ /** A transport on this bus whose answers come from `address`. */
349
+ transport(address: string): IMdnsTransport;
350
+ /** Every packet answered on the bus, in order. */
351
+ readonly responses: readonly IMdnsPacket[];
352
+ }
353
+
354
+ /**
355
+ * An in-process multicast group — for tests and loopback only. A query reaches every member,
356
+ * itself included, as multicast loops back; an answer reaches every member with the answering
357
+ * member's address. Packets cross as JSON and are delivered on a microtask, as a network would.
358
+ */
359
+ export function createInMemoryMdnsBus(): IInMemoryMdnsBus {
360
+ type TListener = (packet: IMdnsPacket, rinfo: IMdnsRemote) => void;
361
+ interface IMember {
362
+ readonly address: string;
363
+ readonly queries: Set<TListener>;
364
+ readonly responses: Set<TListener>;
365
+ }
366
+ const members = new Set<IMember>();
367
+ const responses: IMdnsPacket[] = [];
368
+ const broadcast = (
369
+ from: IMember,
370
+ packet: IMdnsPacket,
371
+ pick: (member: IMember) => Set<TListener>,
372
+ ): void => {
373
+ const copy = JSON.parse(JSON.stringify(packet)) as IMdnsPacket;
374
+ queueMicrotask(() => {
375
+ for (const member of members) {
376
+ for (const listener of pick(member)) listener(copy, { address: from.address });
377
+ }
378
+ });
379
+ };
380
+ return {
381
+ responses,
382
+ transport(address) {
383
+ const self: IMember = { address, queries: new Set(), responses: new Set() };
384
+ members.add(self);
385
+ return {
386
+ on(event: string, listener: TListener) {
387
+ if (event === 'query') self.queries.add(listener);
388
+ if (event === 'response') self.responses.add(listener);
389
+ },
390
+ removeListener(_event, listener) {
391
+ self.responses.delete(listener);
392
+ },
393
+ query(packet) {
394
+ broadcast(self, packet, (m) => m.queries);
395
+ },
396
+ respond(packet) {
397
+ responses.push(JSON.parse(JSON.stringify(packet)) as IMdnsPacket);
398
+ broadcast(self, packet, (m) => m.responses);
399
+ },
400
+ destroy() {
401
+ members.delete(self);
402
+ },
403
+ } as IMdnsTransport;
404
+ },
405
+ };
406
+ }