@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,357 @@
1
+ /**
2
+ * Signed, encrypted rendezvous records as public infrastructure stores them: BEP 44 mutable items
3
+ * (and their pkarr form) under a one-time key per pair, direction, epoch and purpose.
4
+ *
5
+ * Nothing here names a device, a user or the product. The key and salt rotate every epoch and differ
6
+ * per direction; the value is the pair's AEAD ciphertext, padded to a fixed size so its length says
7
+ * nothing about what it holds. Whoever stores or serves a record can check its signature, and only
8
+ * the pair can open it. Isomorphic: WebCrypto only.
9
+ */
10
+ import type {
11
+ IPairRendezvous,
12
+ TRendezvousDirection,
13
+ TRendezvousRecordPurpose,
14
+ TRendezvousTagPurpose,
15
+ } from '@robota-sdk/agent-remote-pairing';
16
+
17
+ import type { IMeshCandidate } from './mesh-discovery.js';
18
+
19
+ /** BEP 44: a value is less than 1000 bytes. */
20
+ export const BEP44_MAX_VALUE_BYTES = 999;
21
+ /** BEP 44: a salt is at most 64 bytes. */
22
+ const BEP44_MAX_SALT_BYTES = 64;
23
+ /** Plaintext size every connection-hints record is padded to. */
24
+ export const HINTS_PADDED_BYTES = 640;
25
+ /** Plaintext size of every device-lists record (one chunk); the pkarr form must still fit. */
26
+ export const LIST_CHUNK_BYTES = 864;
27
+ /** Chunks the device lists may take; a larger list is not published. */
28
+ export const MAX_LIST_CHUNKS = 8;
29
+ /** Bytes of the version every chunk of one list carries. */
30
+ const LIST_VERSION_BYTES = 8;
31
+ const LIST_CHUNK_HEADER = 2 + LIST_VERSION_BYTES;
32
+ /** Addresses one hints record carries. */
33
+ export const MAX_HINT_CANDIDATES = 8;
34
+ /** Relay endpoints one hints record carries. */
35
+ export const MAX_HINT_RELAYS = 4;
36
+ const MAX_HOST_CHARS = 64;
37
+
38
+ const ED25519_PKCS8_PREFIX = Uint8Array.from([
39
+ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
40
+ ]);
41
+
42
+ const encoder = new TextEncoder();
43
+ const decoder = new TextDecoder();
44
+
45
+ function ab(view: Uint8Array): ArrayBuffer {
46
+ return view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) as ArrayBuffer;
47
+ }
48
+
49
+ function base64UrlToBytes(value: string): Uint8Array {
50
+ const binary = atob(value.replace(/-/g, '+').replace(/_/g, '/'));
51
+ const out = new Uint8Array(binary.length);
52
+ for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
53
+ return out;
54
+ }
55
+
56
+ export function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
57
+ const out = new Uint8Array(parts.reduce((sum, p) => sum + p.length, 0));
58
+ let offset = 0;
59
+ for (const part of parts) {
60
+ out.set(part, offset);
61
+ offset += part.length;
62
+ }
63
+ return out;
64
+ }
65
+
66
+ export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
67
+ if (a.length !== b.length) return false;
68
+ let diff = 0;
69
+ for (let i = 0; i < a.length; i += 1) diff |= a[i]! ^ b[i]!;
70
+ return diff === 0;
71
+ }
72
+
73
+ /** A one-time Ed25519 key: the public half, and a signer. */
74
+ export interface IItemKey {
75
+ readonly publicKey: Uint8Array;
76
+ sign(message: Uint8Array): Promise<Uint8Array>;
77
+ }
78
+
79
+ /** The Ed25519 key whose private key is `seed`. */
80
+ export async function ed25519FromSeed(seed: Uint8Array): Promise<IItemKey> {
81
+ if (seed.length !== 32) throw new Error('an Ed25519 seed is 32 bytes');
82
+ const privateKey = await globalThis.crypto.subtle.importKey(
83
+ 'pkcs8',
84
+ ab(concatBytes([ED25519_PKCS8_PREFIX, seed])),
85
+ { name: 'Ed25519' },
86
+ true,
87
+ ['sign'],
88
+ );
89
+ const jwk = await globalThis.crypto.subtle.exportKey('jwk', privateKey);
90
+ if (typeof jwk.x !== 'string') throw new Error('Ed25519 public key unavailable');
91
+ const publicKey = base64UrlToBytes(jwk.x);
92
+ return {
93
+ publicKey,
94
+ sign: async (message) =>
95
+ new Uint8Array(
96
+ await globalThis.crypto.subtle.sign({ name: 'Ed25519' }, privateKey, ab(message)),
97
+ ),
98
+ };
99
+ }
100
+
101
+ /** Whether `signature` is `publicKey`'s Ed25519 signature of `message`. Never throws. */
102
+ export async function verifyEd25519(
103
+ publicKey: Uint8Array,
104
+ message: Uint8Array,
105
+ signature: Uint8Array,
106
+ ): Promise<boolean> {
107
+ if (publicKey.length !== 32 || signature.length !== 64) return false;
108
+ try {
109
+ const key = await globalThis.crypto.subtle.importKey(
110
+ 'raw',
111
+ ab(publicKey),
112
+ { name: 'Ed25519' },
113
+ false,
114
+ ['verify'],
115
+ );
116
+ return await globalThis.crypto.subtle.verify(
117
+ { name: 'Ed25519' },
118
+ key,
119
+ ab(signature),
120
+ ab(message),
121
+ );
122
+ } catch {
123
+ // allow-fallback: a key or signature that cannot be read verifies nothing
124
+ return false;
125
+ }
126
+ }
127
+
128
+ /** A BEP 44 mutable item, as signed. */
129
+ export interface IMutableItem {
130
+ readonly k: Uint8Array;
131
+ readonly salt?: Uint8Array;
132
+ readonly seq: number;
133
+ readonly v: Uint8Array;
134
+ readonly sig: Uint8Array;
135
+ }
136
+
137
+ /** The bytes a BEP 44 signature covers: `[4:salt…]3:seqi…e1:v…` with `v` as a byte string. */
138
+ export function bep44SignedBytes(
139
+ salt: Uint8Array | undefined,
140
+ seq: number,
141
+ v: Uint8Array,
142
+ ): Uint8Array {
143
+ const parts: Uint8Array[] = [];
144
+ if (salt !== undefined && salt.length > 0) {
145
+ parts.push(encoder.encode(`4:salt${salt.length}:`), salt);
146
+ }
147
+ parts.push(encoder.encode(`3:seqi${seq}e1:v${v.length}:`), v);
148
+ return concatBytes(parts);
149
+ }
150
+
151
+ /** Sign `v` as a mutable item under `key` and `salt`. */
152
+ export async function signMutableItem(
153
+ key: IItemKey,
154
+ salt: Uint8Array | undefined,
155
+ seq: number,
156
+ v: Uint8Array,
157
+ ): Promise<IMutableItem> {
158
+ if (v.length > BEP44_MAX_VALUE_BYTES) throw new Error('record value exceeds 999 bytes');
159
+ if (salt !== undefined && salt.length > BEP44_MAX_SALT_BYTES)
160
+ throw new Error('salt exceeds 64 bytes');
161
+ if (!Number.isSafeInteger(seq) || seq < 0) throw new Error('seq must be a non-negative integer');
162
+ const sig = await key.sign(bep44SignedBytes(salt, seq, v));
163
+ return { k: key.publicKey, ...(salt !== undefined ? { salt } : {}), seq, v, sig };
164
+ }
165
+
166
+ /** Whether `item` is signed by `publicKey` under exactly `salt`. Never throws. */
167
+ export async function verifyMutableItem(
168
+ item: IMutableItem,
169
+ publicKey: Uint8Array,
170
+ salt: Uint8Array | undefined,
171
+ ): Promise<boolean> {
172
+ if (!bytesEqual(item.k, publicKey)) return false;
173
+ const itemSalt = item.salt ?? new Uint8Array(0);
174
+ if (!bytesEqual(itemSalt, salt ?? new Uint8Array(0))) return false;
175
+ if (item.v.length > BEP44_MAX_VALUE_BYTES) return false;
176
+ if (!Number.isSafeInteger(item.seq) || item.seq < 0) return false;
177
+ return verifyEd25519(publicKey, bep44SignedBytes(salt, item.seq, item.v), item.sig);
178
+ }
179
+
180
+ /** Where one pair's record of one purpose lives in one direction at one epoch. */
181
+ export interface IItemAddress {
182
+ readonly key: IItemKey;
183
+ readonly salt: Uint8Array;
184
+ }
185
+
186
+ const SALT_PURPOSE: Record<Exclude<TRendezvousRecordPurpose, 'signal'>, TRendezvousTagPurpose> = {
187
+ hints: 'bep44-salt',
188
+ revocation: 'bep44-revocation-salt',
189
+ };
190
+
191
+ async function sha256(parts: readonly Uint8Array[]): Promise<Uint8Array> {
192
+ return new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', ab(concatBytes(parts))));
193
+ }
194
+
195
+ /**
196
+ * The address of chunk `chunk` of `purpose`'s record in `direction` at `epoch`. Every chunk has a key
197
+ * and salt of its own, so chunks never share an item, salted or not.
198
+ */
199
+ export async function itemAddress(
200
+ rendezvous: IPairRendezvous,
201
+ purpose: Exclude<TRendezvousRecordPurpose, 'signal'>,
202
+ direction: TRendezvousDirection,
203
+ epoch: number,
204
+ chunk = 0,
205
+ ): Promise<IItemAddress> {
206
+ const [seed, salt] = await Promise.all([
207
+ rendezvous.signingSeed(direction, epoch, purpose),
208
+ rendezvous.tag(SALT_PURPOSE[purpose], direction, epoch),
209
+ ]);
210
+ if (chunk === 0) return { key: await ed25519FromSeed(seed), salt };
211
+ const label = encoder.encode('chunk');
212
+ const index = Uint8Array.of(chunk);
213
+ const [chunkSeed, chunkSalt] = await Promise.all([
214
+ sha256([label, seed, index]),
215
+ sha256([label, salt, index]),
216
+ ]);
217
+ return { key: await ed25519FromSeed(chunkSeed), salt: chunkSalt };
218
+ }
219
+
220
+ /**
221
+ * `value` as fixed-size chunks, each `[count, index, version, …JSON]` padded with spaces. Every chunk
222
+ * of one value carries the same version, derived from the value, so chunks of two values are never
223
+ * joined. Throws when it needs more than {@link MAX_LIST_CHUNKS}.
224
+ */
225
+ export async function chunkJson(value: unknown): Promise<Uint8Array[]> {
226
+ const bytes = encoder.encode(JSON.stringify(value));
227
+ const version = (await sha256([bytes])).subarray(0, LIST_VERSION_BYTES);
228
+ const room = LIST_CHUNK_BYTES - LIST_CHUNK_HEADER;
229
+ const count = Math.max(1, Math.ceil(bytes.length / room));
230
+ if (count > MAX_LIST_CHUNKS) throw new Error('device lists too large to publish as records');
231
+ return Array.from({ length: count }, (_, index) => {
232
+ const out = new Uint8Array(LIST_CHUNK_BYTES).fill(0x20);
233
+ out[0] = count;
234
+ out[1] = index;
235
+ out.set(version, 2);
236
+ out.set(bytes.subarray(index * room, (index + 1) * room), LIST_CHUNK_HEADER);
237
+ return out;
238
+ });
239
+ }
240
+
241
+ /** How many chunks the value has, from its first chunk; `undefined` when it is not one. */
242
+ export function chunkCount(first: Uint8Array): number | undefined {
243
+ const count = first[0];
244
+ if (first.length !== LIST_CHUNK_BYTES || first[1] !== 0) return undefined;
245
+ return count !== undefined && count >= 1 && count <= MAX_LIST_CHUNKS ? count : undefined;
246
+ }
247
+
248
+ /**
249
+ * The value of `chunks` in order; `undefined` when they are not all chunks of one value, as when a
250
+ * read finds some chunks already replaced by a newer value's.
251
+ */
252
+ export function joinChunks(chunks: readonly Uint8Array[]): unknown {
253
+ const count = chunks.length;
254
+ const version = chunks[0]?.subarray(2, LIST_CHUNK_HEADER);
255
+ const parts: Uint8Array[] = [];
256
+ for (const [index, chunk] of chunks.entries()) {
257
+ if (chunk.length !== LIST_CHUNK_BYTES || chunk[0] !== count || chunk[1] !== index) {
258
+ return undefined;
259
+ }
260
+ if (!sameBytes(chunk.subarray(2, LIST_CHUNK_HEADER), version)) return undefined;
261
+ parts.push(chunk.subarray(LIST_CHUNK_HEADER));
262
+ }
263
+ return unpadJson(concatBytes(parts));
264
+ }
265
+
266
+ function sameBytes(a: Uint8Array, b: Uint8Array | undefined): boolean {
267
+ return b !== undefined && a.length === b.length && a.every((byte, i) => byte === b[i]);
268
+ }
269
+
270
+ /** `data` padded with trailing spaces to exactly `size` bytes; throws when it does not fit. */
271
+ export function padJson(value: unknown, size: number): Uint8Array {
272
+ const bytes = encoder.encode(JSON.stringify(value));
273
+ if (bytes.length > size) throw new Error('record does not fit its fixed size');
274
+ const out = new Uint8Array(size).fill(0x20);
275
+ out.set(bytes);
276
+ return out;
277
+ }
278
+
279
+ /** Parse padded JSON; `undefined` when it is not JSON. */
280
+ export function unpadJson(bytes: Uint8Array): unknown {
281
+ try {
282
+ return JSON.parse(decoder.decode(bytes)) as unknown;
283
+ } catch {
284
+ return undefined;
285
+ }
286
+ }
287
+
288
+ function keptAddresses(addresses: readonly IMeshCandidate[], max: number): [string, number][] {
289
+ const kept: [string, number][] = [];
290
+ for (const c of addresses) {
291
+ if (kept.length >= max) break;
292
+ if (c.host.length === 0 || c.host.length > MAX_HOST_CHARS) continue;
293
+ kept.push([c.host, c.port]);
294
+ }
295
+ return kept;
296
+ }
297
+
298
+ /**
299
+ * Connection hints: where the peer's direct signaling endpoint might be reached and, when this
300
+ * device runs a relay, where that relay listens. What does not fit the fixed size is left out,
301
+ * signaling addresses first.
302
+ */
303
+ export function encodeHints(
304
+ candidates: readonly IMeshCandidate[],
305
+ relays: readonly IMeshCandidate[] = [],
306
+ ): Uint8Array {
307
+ const c = keptAddresses(candidates, MAX_HINT_CANDIDATES);
308
+ const t = keptAddresses(relays, MAX_HINT_RELAYS);
309
+ for (;;) {
310
+ try {
311
+ return padJson({ v: 1, c, ...(t.length > 0 ? { t } : {}) }, HINTS_PADDED_BYTES);
312
+ } catch (error) {
313
+ if (c.length > 0) c.pop();
314
+ else if (t.length > 0) t.pop();
315
+ else throw error;
316
+ }
317
+ }
318
+ }
319
+
320
+ const HOST = /^[0-9A-Za-z.:%_-]{1,64}$/;
321
+
322
+ /** The candidates of a hints record; hostile input, so anything malformed is dropped. */
323
+ export function decodeHints(bytes: Uint8Array): IMeshCandidate[] {
324
+ return decodeHintAddresses(bytes, 'c', MAX_HINT_CANDIDATES);
325
+ }
326
+
327
+ /** The relay endpoints of a hints record, if the peer runs a relay; hostile input like the rest. */
328
+ export function decodeRelayHints(bytes: Uint8Array): IMeshCandidate[] {
329
+ return decodeHintAddresses(bytes, 't', MAX_HINT_RELAYS);
330
+ }
331
+
332
+ function decodeHintAddresses(bytes: Uint8Array, field: 'c' | 't', max: number): IMeshCandidate[] {
333
+ const value = unpadJson(bytes);
334
+ if (typeof value !== 'object' || value === null) return [];
335
+ const r = value as { v?: unknown; c?: unknown; t?: unknown };
336
+ const list = r[field];
337
+ if (r.v !== 1 || !Array.isArray(list)) return [];
338
+ const out: IMeshCandidate[] = [];
339
+ for (const entry of list.slice(0, max)) {
340
+ if (!Array.isArray(entry) || entry.length !== 2) continue;
341
+ const [host, port] = entry as [unknown, unknown];
342
+ if (typeof host !== 'string' || !HOST.test(host)) continue;
343
+ if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65_535) continue;
344
+ out.push({ host, port });
345
+ }
346
+ return out;
347
+ }
348
+
349
+ /**
350
+ * A publish delay in `[0, maxMs]`, so the records one device publishes for its pairs do not all
351
+ * appear at once and cannot be linked by their timing.
352
+ */
353
+ export function publishJitter(maxMs: number, random: () => number = Math.random): number {
354
+ const r = random();
355
+ const unit = Number.isFinite(r) ? Math.min(Math.max(r, 0), 1) : 0;
356
+ return Math.floor(unit * Math.max(0, maxMs));
357
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The relay port a device mesh node signals through: be present at inbox topics, send an opaque
3
+ * message to a topic, and hear back when nobody is present there.
4
+ *
5
+ * The relay is untrusted. It sees only opaque topics and signaling blobs; everything a node reads
6
+ * from it is decoded as hostile input, and admission never depends on it.
7
+ */
8
+
9
+ import type { IMeshPeerRoute } from './mesh-discovery.js';
10
+
11
+ export interface IMeshRelay {
12
+ /** Be present at exactly these topics (replaces any earlier declaration). */
13
+ declarePresence(topics: readonly string[]): void;
14
+ /** Deliver `data` to whoever is present at `topic`. */
15
+ send(topic: string, data: unknown): void;
16
+ /** Messages delivered to a topic this relay client is present at. Returns an unsubscribe. */
17
+ onMessage(handler: (topic: string, data: unknown) => void): () => void;
18
+ /** Nobody was present at `topic` when a message was sent there. Returns an unsubscribe. */
19
+ onAbsent(handler: (topic: string) => void): () => void;
20
+ /**
21
+ * The peers the node signals now (replaces any earlier declaration), for a relay that looks for
22
+ * peers itself rather than only delivering to topics.
23
+ */
24
+ declarePeers?(peers: readonly IMeshPeerRoute[]): void;
25
+ /** A connection to `deviceId` was admitted: the way its signals went worked. */
26
+ confirmPeer?(deviceId: string): void;
27
+ close(): void;
28
+ }
29
+
30
+ export interface IInMemoryMeshRelayHub {
31
+ /** A new relay client attached to this hub. */
32
+ connect(): IMeshRelay;
33
+ }
34
+
35
+ /**
36
+ * An in-process relay hub with the relay's delivery rules — for tests and loopback only: one holder
37
+ * per topic (the newest), never an echo to the sender, `absent` when nobody holds the topic.
38
+ * Deliveries run on a microtask, as a network would never deliver synchronously.
39
+ */
40
+ export function createInMemoryMeshRelayHub(): IInMemoryMeshRelayHub {
41
+ interface IClient {
42
+ readonly messages: Set<(topic: string, data: unknown) => void>;
43
+ readonly absents: Set<(topic: string) => void>;
44
+ topics: Set<string>;
45
+ open: boolean;
46
+ }
47
+ const holders = new Map<string, IClient>();
48
+
49
+ return {
50
+ connect(): IMeshRelay {
51
+ const self: IClient = {
52
+ messages: new Set(),
53
+ absents: new Set(),
54
+ topics: new Set(),
55
+ open: true,
56
+ };
57
+ const withdraw = (): void => {
58
+ for (const topic of self.topics) if (holders.get(topic) === self) holders.delete(topic);
59
+ self.topics = new Set();
60
+ };
61
+ return {
62
+ declarePresence(topics) {
63
+ if (!self.open) return;
64
+ withdraw();
65
+ self.topics = new Set(topics);
66
+ for (const topic of topics) holders.set(topic, self);
67
+ },
68
+ send(topic, data) {
69
+ if (!self.open) return;
70
+ // Cross the boundary as JSON, as a real relay would.
71
+ const copy: unknown = JSON.parse(JSON.stringify(data));
72
+ queueMicrotask(() => {
73
+ const holder = holders.get(topic);
74
+ if (holder === undefined || holder === self || !holder.open) {
75
+ for (const handler of self.absents) handler(topic);
76
+ return;
77
+ }
78
+ for (const handler of holder.messages) handler(topic, copy);
79
+ });
80
+ },
81
+ onMessage(handler) {
82
+ self.messages.add(handler);
83
+ return () => self.messages.delete(handler);
84
+ },
85
+ onAbsent(handler) {
86
+ self.absents.add(handler);
87
+ return () => self.absents.delete(handler);
88
+ },
89
+ close() {
90
+ self.open = false;
91
+ withdraw();
92
+ self.messages.clear();
93
+ self.absents.clear();
94
+ },
95
+ };
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * What two device mesh nodes say to each other through the relay, and the frames their data channel
3
+ * carries. Everything here arrives from an untrusted relay or an unadmitted peer, so every field is
4
+ * bounded and shape-checked before it is used, and a failure names no value.
5
+ *
6
+ * `hello` is presence: a node announces its instance to a peer's inbox. The instance is a random id
7
+ * per node run, so a restarted peer is told apart from its previous run without naming the device.
8
+ * An attempt (`cid`) scopes an offer, its answer and their ICE candidates to one connection.
9
+ */
10
+
11
+ /** A per-run instance id or a connection-attempt id: 128 random bits, base64url. */
12
+ const RANDOM_ID = /^[A-Za-z0-9_-]{22}$/;
13
+ /** An SDP a relay frame can carry; the relay itself caps a frame well below this. */
14
+ const MAX_SDP_CHARS = 48 * 1024;
15
+ const MAX_CANDIDATE_CHARS = 2048;
16
+ /** Largest message body a data-channel `mesh-msg` frame carries. */
17
+ export const MAX_MESH_MESSAGE_CHARS = 60_000;
18
+
19
+ export const MESH_SIGNAL_VERSION = 1;
20
+
21
+ export interface IMeshHello {
22
+ readonly v: typeof MESH_SIGNAL_VERSION;
23
+ readonly kind: 'hello';
24
+ readonly from: string;
25
+ }
26
+
27
+ export interface IMeshDescription {
28
+ readonly v: typeof MESH_SIGNAL_VERSION;
29
+ readonly kind: 'offer' | 'answer';
30
+ readonly from: string;
31
+ readonly to: string;
32
+ readonly cid: string;
33
+ readonly sdp: string;
34
+ }
35
+
36
+ export interface IMeshIce {
37
+ readonly v: typeof MESH_SIGNAL_VERSION;
38
+ readonly kind: 'ice';
39
+ readonly from: string;
40
+ readonly to: string;
41
+ readonly cid: string;
42
+ readonly candidate: {
43
+ readonly candidate: string;
44
+ readonly sdpMid?: string;
45
+ readonly sdpMLineIndex?: number;
46
+ };
47
+ }
48
+
49
+ export type TMeshSignal = IMeshHello | IMeshDescription | IMeshIce;
50
+
51
+ function isId(value: unknown): value is string {
52
+ return typeof value === 'string' && RANDOM_ID.test(value);
53
+ }
54
+
55
+ function isBoundedString(value: unknown, max: number): value is string {
56
+ return typeof value === 'string' && value.length > 0 && value.length <= max;
57
+ }
58
+
59
+ /** A relay-delivered signal, or `undefined` when it is not one this node would act on. */
60
+ export function decodeMeshSignal(value: unknown): TMeshSignal | undefined {
61
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
62
+ const r = value as Record<string, unknown>;
63
+ if (r.v !== MESH_SIGNAL_VERSION || !isId(r.from)) return undefined;
64
+ if (r.kind === 'hello') return { v: MESH_SIGNAL_VERSION, kind: 'hello', from: r.from };
65
+ if (!isId(r.to) || !isId(r.cid)) return undefined;
66
+ if (r.kind === 'offer' || r.kind === 'answer') {
67
+ if (!isBoundedString(r.sdp, MAX_SDP_CHARS)) return undefined;
68
+ return { v: MESH_SIGNAL_VERSION, kind: r.kind, from: r.from, to: r.to, cid: r.cid, sdp: r.sdp };
69
+ }
70
+ if (r.kind === 'ice') {
71
+ const c = r.candidate;
72
+ if (typeof c !== 'object' || c === null || Array.isArray(c)) return undefined;
73
+ const cr = c as Record<string, unknown>;
74
+ if (!isBoundedString(cr.candidate, MAX_CANDIDATE_CHARS)) return undefined;
75
+ if (cr.sdpMid !== undefined && !isBoundedString(cr.sdpMid, 64)) return undefined;
76
+ if (
77
+ cr.sdpMLineIndex !== undefined &&
78
+ !(
79
+ typeof cr.sdpMLineIndex === 'number' &&
80
+ Number.isInteger(cr.sdpMLineIndex) &&
81
+ cr.sdpMLineIndex >= 0 &&
82
+ cr.sdpMLineIndex < 64
83
+ )
84
+ ) {
85
+ return undefined;
86
+ }
87
+ return {
88
+ v: MESH_SIGNAL_VERSION,
89
+ kind: 'ice',
90
+ from: r.from,
91
+ to: r.to,
92
+ cid: r.cid,
93
+ candidate: {
94
+ candidate: cr.candidate,
95
+ ...(cr.sdpMid !== undefined ? { sdpMid: cr.sdpMid as string } : {}),
96
+ ...(cr.sdpMLineIndex !== undefined ? { sdpMLineIndex: cr.sdpMLineIndex as number } : {}),
97
+ },
98
+ };
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ /** The one frame an admitted link carries. */
104
+ export interface IMeshMessageFrame {
105
+ readonly t: 'mesh-msg';
106
+ readonly body: string;
107
+ }
108
+
109
+ export function decodeMeshMessageFrame(value: unknown): IMeshMessageFrame | undefined {
110
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
111
+ const r = value as Record<string, unknown>;
112
+ if (r.t !== 'mesh-msg' || typeof r.body !== 'string' || r.body.length > MAX_MESH_MESSAGE_CHARS) {
113
+ return undefined;
114
+ }
115
+ return { t: 'mesh-msg', body: r.body };
116
+ }