@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,287 @@
1
+ /**
2
+ * Where rendezvous records are published: a store of signed mutable items that anyone may run and
3
+ * nobody is trusted to run honestly.
4
+ *
5
+ * A store checks every item it returns against the key and salt it was asked for, so a node or relay
6
+ * that serves a tampered item, or one signed by another key, yields nothing. What an item holds is
7
+ * the pair's ciphertext; opening it is the caller's business. Isomorphic: WebCrypto and `fetch`.
8
+ */
9
+ import {
10
+ BEP44_MAX_VALUE_BYTES,
11
+ concatBytes,
12
+ signMutableItem,
13
+ verifyMutableItem,
14
+ bep44SignedBytes,
15
+ verifyEd25519,
16
+ type IItemAddress,
17
+ type IMutableItem,
18
+ } from './mesh-records.js';
19
+
20
+ export interface IRendezvousItemStore {
21
+ /** Publish `value` at `address`, as of `now` (ms); rejects when no holder took it. */
22
+ put(address: IItemAddress, value: Uint8Array, now: number, signal?: AbortSignal): Promise<void>;
23
+ /** The verified value at `publicKey` and `salt`; `undefined` when none verifies. Never throws. */
24
+ get(
25
+ publicKey: Uint8Array,
26
+ salt: Uint8Array,
27
+ signal: AbortSignal,
28
+ ): Promise<Uint8Array | undefined>;
29
+ close(): void;
30
+ }
31
+
32
+ function hex(bytes: Uint8Array): string {
33
+ let out = '';
34
+ for (const b of bytes) out += b.toString(16).padStart(2, '0');
35
+ return out;
36
+ }
37
+
38
+ // ── In-process network, for tests and loopback ─────────────────────────────────────────────────
39
+
40
+ export interface IInMemoryItemNetwork {
41
+ /** A store client attached to this network. */
42
+ store(): IRendezvousItemStore;
43
+ /** Every item the network holds, as stored. */
44
+ items(): readonly IMutableItem[];
45
+ /** Replace what the network serves (a hostile node): return the item to serve, or `undefined`. */
46
+ serve(rewrite: (item: IMutableItem) => IMutableItem | undefined): void;
47
+ }
48
+
49
+ /**
50
+ * An in-process item network with a DHT node's rules: it refuses an item whose signature does not
51
+ * verify, and a sequence number lower than the one it holds.
52
+ */
53
+ export function createInMemoryItemNetwork(): IInMemoryItemNetwork {
54
+ const held = new Map<string, IMutableItem>();
55
+ let rewrite: (item: IMutableItem) => IMutableItem | undefined = (item) => item;
56
+ const target = (k: Uint8Array, salt: Uint8Array | undefined): string =>
57
+ `${hex(k)}/${hex(salt ?? new Uint8Array(0))}`;
58
+ return {
59
+ store: () => ({
60
+ async put(address, value, now) {
61
+ const item = await signMutableItem(
62
+ address.key,
63
+ address.salt,
64
+ Math.floor(now / 1000),
65
+ value,
66
+ );
67
+ if (!(await verifyMutableItem(item, item.k, item.salt))) throw new Error('item refused');
68
+ const at = target(item.k, item.salt);
69
+ const current = held.get(at);
70
+ if (current !== undefined && current.seq > item.seq)
71
+ throw new Error('item refused: older seq');
72
+ held.set(at, item);
73
+ },
74
+ async get(publicKey, salt) {
75
+ await Promise.resolve();
76
+ const stored = held.get(target(publicKey, salt));
77
+ const served = stored === undefined ? undefined : rewrite(stored);
78
+ if (served === undefined) return undefined;
79
+ return (await verifyMutableItem(served, publicKey, salt)) ? served.v : undefined;
80
+ },
81
+ close: () => undefined,
82
+ }),
83
+ items: () => [...held.values()],
84
+ serve: (next) => {
85
+ rewrite = next;
86
+ },
87
+ };
88
+ }
89
+
90
+ // ── pkarr relays (HTTP), for clients that cannot reach the DHT ─────────────────────────────────
91
+
92
+ const Z_BASE32 = 'ybndrfg8ejkmcpqxot1uwisza345h769';
93
+
94
+ /** z-base-32, as pkarr names keys. */
95
+ export function zBase32(bytes: Uint8Array): string {
96
+ let out = '';
97
+ let buffer = 0;
98
+ let bits = 0;
99
+ for (const byte of bytes) {
100
+ buffer = (buffer << 8) | byte;
101
+ bits += 8;
102
+ while (bits >= 5) {
103
+ out += Z_BASE32[(buffer >>> (bits - 5)) & 31];
104
+ bits -= 5;
105
+ }
106
+ }
107
+ if (bits > 0) out += Z_BASE32[(buffer << (5 - bits)) & 31];
108
+ return out;
109
+ }
110
+
111
+ const DNS_TYPE_TXT = 16;
112
+ const DNS_CLASS_IN = 1;
113
+ const TXT_TTL_S = 300;
114
+ const RECORD_LABEL = '_r';
115
+
116
+ function u16(value: number): Uint8Array {
117
+ return Uint8Array.from([(value >>> 8) & 0xff, value & 0xff]);
118
+ }
119
+
120
+ /**
121
+ * The DNS packet a pkarr relay carries: one TXT answer holding `value`. pkarr relays accept only a
122
+ * DNS packet as the value, and no salt, so the pkarr form of a record is the salt-less item under
123
+ * the same one-time key, whose value is this packet.
124
+ */
125
+ export function encodePkarrPacket(publicKey: Uint8Array, value: Uint8Array): Uint8Array {
126
+ const labels = [RECORD_LABEL, zBase32(publicKey)];
127
+ const name = concatBytes([
128
+ ...labels.flatMap((l) => [Uint8Array.of(l.length), new TextEncoder().encode(l)]),
129
+ Uint8Array.of(0),
130
+ ]);
131
+ const chunks: Uint8Array[] = [];
132
+ for (let i = 0; i < value.length; i += 255) {
133
+ const part = value.subarray(i, i + 255);
134
+ chunks.push(Uint8Array.of(part.length), part);
135
+ }
136
+ if (value.length === 0) chunks.push(Uint8Array.of(0));
137
+ const rdata = concatBytes(chunks);
138
+ return concatBytes([
139
+ // id 0, flags: response + authoritative, 0 questions, 1 answer, 0 authority, 0 additional
140
+ Uint8Array.from([0, 0, 0x84, 0, 0, 0, 0, 1, 0, 0, 0, 0]),
141
+ name,
142
+ u16(DNS_TYPE_TXT),
143
+ u16(DNS_CLASS_IN),
144
+ Uint8Array.from([0, 0, (TXT_TTL_S >>> 8) & 0xff, TXT_TTL_S & 0xff]),
145
+ u16(rdata.length),
146
+ rdata,
147
+ ]);
148
+ }
149
+
150
+ /** The value of the first TXT answer of a pkarr packet; `undefined` when there is none. */
151
+ export function decodePkarrPacket(packet: Uint8Array): Uint8Array | undefined {
152
+ const read16 = (at: number): number => ((packet[at] ?? 0) << 8) | (packet[at + 1] ?? 0);
153
+ if (packet.length < 12) return undefined;
154
+ const questions = read16(4);
155
+ const answers = read16(6);
156
+ let at = 12;
157
+ const skipName = (): boolean => {
158
+ for (let hops = 0; hops < 128; hops += 1) {
159
+ const len = packet[at];
160
+ if (len === undefined) return false;
161
+ if (len === 0) {
162
+ at += 1;
163
+ return true;
164
+ }
165
+ if ((len & 0xc0) === 0xc0) {
166
+ at += 2;
167
+ return true;
168
+ }
169
+ at += 1 + len;
170
+ }
171
+ return false;
172
+ };
173
+ for (let q = 0; q < questions; q += 1) {
174
+ if (!skipName()) return undefined;
175
+ at += 4;
176
+ }
177
+ for (let a = 0; a < answers; a += 1) {
178
+ if (!skipName() || at + 10 > packet.length) return undefined;
179
+ const type = read16(at);
180
+ const rdLength = read16(at + 8);
181
+ const start = at + 10;
182
+ const end = start + rdLength;
183
+ if (end > packet.length) return undefined;
184
+ if (type === DNS_TYPE_TXT) {
185
+ const parts: Uint8Array[] = [];
186
+ let p = start;
187
+ while (p < end) {
188
+ const len = packet[p]!;
189
+ if (p + 1 + len > end) return undefined;
190
+ parts.push(packet.subarray(p + 1, p + 1 + len));
191
+ p += 1 + len;
192
+ }
193
+ return concatBytes(parts);
194
+ }
195
+ at = end;
196
+ }
197
+ return undefined;
198
+ }
199
+
200
+ export interface IPkarrRelayStoreOptions {
201
+ /** Relay base URLs (`https://…`), each run by whoever the user chose; several operators. */
202
+ readonly relays: readonly string[];
203
+ /** Default: the global `fetch`. */
204
+ readonly fetch?: typeof fetch;
205
+ }
206
+
207
+ const PKARR_CONTENT_TYPE = 'application/pkarr.org/relays#payload';
208
+ const SIG_BYTES = 64;
209
+ const SEQ_BYTES = 8;
210
+
211
+ function seqBytes(seq: number): Uint8Array {
212
+ const out = new Uint8Array(SEQ_BYTES);
213
+ new DataView(out.buffer).setBigUint64(0, BigInt(seq));
214
+ return out;
215
+ }
216
+
217
+ function withoutTrailingSlashes(url: string): string {
218
+ let end = url.length;
219
+ while (end > 0 && url[end - 1] === '/') end -= 1;
220
+ return url.slice(0, end);
221
+ }
222
+
223
+ /**
224
+ * A store over several pkarr relays. A put goes to every relay and succeeds when one took it; a get
225
+ * asks every relay and keeps the newest item that verifies. A relay is only a cache in front of the
226
+ * DHT: it can withhold or serve stale items, never forge one.
227
+ */
228
+ export function createPkarrRelayStore(options: IPkarrRelayStoreOptions): IRendezvousItemStore {
229
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
230
+ const urlOf = (relay: string, publicKey: Uint8Array): string =>
231
+ `${withoutTrailingSlashes(relay)}/${zBase32(publicKey)}`;
232
+ return {
233
+ async put(address, value, now, signal) {
234
+ const packet = encodePkarrPacket(address.key.publicKey, value);
235
+ if (packet.length > BEP44_MAX_VALUE_BYTES)
236
+ throw new Error('record too large for a pkarr relay');
237
+ const seq = Math.floor(now) * 1000;
238
+ const sig = await address.key.sign(bep44SignedBytes(undefined, seq, packet));
239
+ const body = concatBytes([sig, seqBytes(seq), packet]);
240
+ const results = await Promise.allSettled(
241
+ options.relays.map(async (relay) => {
242
+ const response = await doFetch(urlOf(relay, address.key.publicKey), {
243
+ method: 'PUT',
244
+ headers: { 'content-type': PKARR_CONTENT_TYPE },
245
+ body: body.slice().buffer,
246
+ ...(signal !== undefined ? { signal } : {}),
247
+ });
248
+ if (!response.ok) throw new Error(`pkarr relay refused (${response.status})`);
249
+ }),
250
+ );
251
+ if (!results.some((r) => r.status === 'fulfilled')) {
252
+ throw new Error('no pkarr relay took the record');
253
+ }
254
+ },
255
+ async get(publicKey, _salt, signal) {
256
+ const found = await Promise.all(
257
+ options.relays.map(async (relay) => {
258
+ try {
259
+ const response = await doFetch(urlOf(relay, publicKey), { signal });
260
+ if (!response.ok) return undefined;
261
+ const body = new Uint8Array(await response.arrayBuffer());
262
+ if (body.length <= SIG_BYTES + SEQ_BYTES || body.length > 1_200) return undefined;
263
+ const sig = body.subarray(0, SIG_BYTES);
264
+ const seq = Number(
265
+ new DataView(body.buffer, body.byteOffset + SIG_BYTES, SEQ_BYTES).getBigUint64(0),
266
+ );
267
+ const packet = body.slice(SIG_BYTES + SEQ_BYTES);
268
+ if (!Number.isSafeInteger(seq)) return undefined;
269
+ if (!(await verifyEd25519(publicKey, bep44SignedBytes(undefined, seq, packet), sig))) {
270
+ return undefined;
271
+ }
272
+ const value = decodePkarrPacket(packet);
273
+ return value === undefined ? undefined : { seq, value };
274
+ } catch {
275
+ // allow-fallback: a relay that fails or times out has nothing; the others are asked too
276
+ return undefined;
277
+ }
278
+ }),
279
+ );
280
+ let best: { seq: number; value: Uint8Array } | undefined;
281
+ for (const entry of found)
282
+ if (entry !== undefined && (best === undefined || entry.seq > best.seq)) best = entry;
283
+ return best?.value;
284
+ },
285
+ close: () => undefined,
286
+ };
287
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * This device's direct signaling endpoint on the local network: a peer that found it (by the address
3
+ * cache or mDNS) delivers mesh signals here instead of through a relay.
4
+ *
5
+ * It is a mailbox with a relay's rules and nothing more. It delivers only to the rotating pairwise
6
+ * topics this device holds and treats every byte as hostile: frames and connections are bounded, a
7
+ * connection that floods is closed, a browser page is turned away, and nothing that arrives here is
8
+ * trusted — the peer is admitted by the device handshake over the WebRTC channel these signals set
9
+ * up, or not at all.
10
+ *
11
+ * A topic never crosses the network. Frames are addressed by its hash, and an endpoint proves it
12
+ * holds a topic by a MAC under the topic over the prober's nonce, so an endpoint that merely echoes
13
+ * what it is sent, or replays what it overheard, cannot pass for the peer and capture the pair's
14
+ * signals.
15
+ */
16
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
17
+
18
+ import { WebSocketServer, type WebSocket } from 'ws';
19
+
20
+ /** A LAN address (a topic's hash) or a nonce: base64url. */
21
+ const LAN_ADDRESS = /^[A-Za-z0-9_-]{43}$/;
22
+ const NONCE = /^[A-Za-z0-9_-]{22}$/;
23
+ /** Largest frame accepted; an SDP with its candidates fits well within it. */
24
+ export const MAX_LAN_FRAME_BYTES = 64 * 1024;
25
+ const MAX_CONNECTIONS = 32;
26
+ /** Frames a connection may send per window before it is closed. */
27
+ const MAX_FRAMES_PER_WINDOW = 128;
28
+ const FRAME_WINDOW_MS = 10_000;
29
+ /** A connection that says nothing for this long is closed. */
30
+ const IDLE_MS = 120_000;
31
+ const POLICY_VIOLATION = 1008;
32
+
33
+ export interface IMeshLanListener {
34
+ /** The port it listens on. */
35
+ readonly port: number;
36
+ /** Hold exactly these topics (replaces any earlier set). */
37
+ setPresence(topics: readonly string[]): void;
38
+ /** A signal delivered to a held topic, named by the topic. Returns an unsubscribe. */
39
+ onMessage(handler: (topic: string, data: unknown) => void): () => void;
40
+ close(): Promise<void>;
41
+ }
42
+
43
+ export interface IMeshLanListenerOptions {
44
+ /** The port to listen on; when it is taken, any free port. Default: any free port. */
45
+ readonly port?: number;
46
+ /** The address to bind; default: every interface. */
47
+ readonly host?: string;
48
+ }
49
+
50
+ /** Where frames for `topic` are addressed on the network. */
51
+ export function lanAddressOf(topic: string): string {
52
+ return createHash('sha256').update(`lan-address:${topic}`).digest('base64url');
53
+ }
54
+
55
+ /** What an endpoint holding `topic` answers a probe carrying `nonce` with. */
56
+ export function lanPresenceProof(topic: string, nonce: string): string {
57
+ return createHmac('sha256', topic).update(`lan-present:${nonce}`).digest('base64url');
58
+ }
59
+
60
+ /** Whether `proof` shows its sender holds `topic`. */
61
+ export function verifyLanPresenceProof(topic: string, nonce: string, proof: unknown): boolean {
62
+ if (typeof proof !== 'string') return false;
63
+ const expected = Buffer.from(lanPresenceProof(topic, nonce));
64
+ const given = Buffer.from(proof);
65
+ return given.length === expected.length && timingSafeEqual(given, expected);
66
+ }
67
+
68
+ function listen(port: number, host: string | undefined): Promise<WebSocketServer> {
69
+ return new Promise((resolve, reject) => {
70
+ const server = new WebSocketServer({
71
+ port,
72
+ ...(host !== undefined ? { host } : {}),
73
+ maxPayload: MAX_LAN_FRAME_BYTES,
74
+ perMessageDeflate: false,
75
+ // A peer device sends no Origin; a browser always does, and no web page has business here.
76
+ verifyClient: (info: { origin?: string }) => info.origin === undefined || info.origin === '',
77
+ });
78
+ const onError = (error: Error): void => {
79
+ server.close();
80
+ reject(error);
81
+ };
82
+ server.once('error', onError);
83
+ server.once('listening', () => {
84
+ server.off('error', onError);
85
+ resolve(server);
86
+ });
87
+ });
88
+ }
89
+
90
+ function decodeFrame(
91
+ raw: unknown,
92
+ ): { type: string; to: string; nonce?: string; data?: unknown } | undefined {
93
+ let frame: unknown;
94
+ try {
95
+ frame = JSON.parse(String(raw));
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ if (typeof frame !== 'object' || frame === null || Array.isArray(frame)) return undefined;
100
+ const r = frame as Record<string, unknown>;
101
+ if (typeof r.type !== 'string' || typeof r.to !== 'string' || !LAN_ADDRESS.test(r.to)) {
102
+ return undefined;
103
+ }
104
+ if (r.type === 'probe') {
105
+ return typeof r.nonce === 'string' && NONCE.test(r.nonce)
106
+ ? { type: r.type, to: r.to, nonce: r.nonce }
107
+ : undefined;
108
+ }
109
+ return { type: r.type, to: r.to, data: r.data };
110
+ }
111
+
112
+ export async function startMeshLanListener(
113
+ options: IMeshLanListenerOptions = {},
114
+ ): Promise<IMeshLanListener> {
115
+ let server: WebSocketServer;
116
+ try {
117
+ server = await listen(options.port ?? 0, options.host);
118
+ } catch (error) {
119
+ // allow-fallback: the remembered port is only a preference; any free port serves, peers find it again
120
+ if (options.port === undefined || options.port === 0) throw error;
121
+ server = await listen(0, options.host);
122
+ }
123
+ const address = server.address();
124
+ const port = typeof address === 'object' && address !== null ? address.port : 0;
125
+ /** Address → topic. */
126
+ let held = new Map<string, string>();
127
+ const handlers = new Set<(topic: string, data: unknown) => void>();
128
+
129
+ server.on('connection', (socket: WebSocket) => {
130
+ if (server.clients.size > MAX_CONNECTIONS) {
131
+ socket.close(POLICY_VIOLATION);
132
+ return;
133
+ }
134
+ let frames = 0;
135
+ let windowStart = Date.now();
136
+ let idle = setTimeout(() => socket.close(), IDLE_MS);
137
+ idle.unref?.();
138
+ socket.on('close', () => clearTimeout(idle));
139
+ socket.on('error', () => socket.close());
140
+ socket.on('message', (raw: unknown) => {
141
+ const now = Date.now();
142
+ if (now - windowStart > FRAME_WINDOW_MS) {
143
+ windowStart = now;
144
+ frames = 0;
145
+ }
146
+ frames += 1;
147
+ if (frames > MAX_FRAMES_PER_WINDOW) {
148
+ socket.close(POLICY_VIOLATION);
149
+ return;
150
+ }
151
+ clearTimeout(idle);
152
+ idle = setTimeout(() => socket.close(), IDLE_MS);
153
+ idle.unref?.();
154
+ const frame = decodeFrame(raw);
155
+ if (frame === undefined) return;
156
+ const topic = held.get(frame.to);
157
+ if (topic === undefined) {
158
+ socket.send(JSON.stringify({ type: 'absent', to: frame.to }));
159
+ return;
160
+ }
161
+ if (frame.type === 'probe' && frame.nonce !== undefined) {
162
+ socket.send(
163
+ JSON.stringify({
164
+ type: 'present',
165
+ to: frame.to,
166
+ nonce: frame.nonce,
167
+ proof: lanPresenceProof(topic, frame.nonce),
168
+ }),
169
+ );
170
+ return;
171
+ }
172
+ if (frame.type !== 'message') return;
173
+ for (const handler of handlers) handler(topic, frame.data);
174
+ });
175
+ });
176
+
177
+ return {
178
+ port,
179
+ setPresence(topics) {
180
+ held = new Map(topics.map((topic) => [lanAddressOf(topic), topic]));
181
+ },
182
+ onMessage(handler) {
183
+ handlers.add(handler);
184
+ return () => handlers.delete(handler);
185
+ },
186
+ close() {
187
+ handlers.clear();
188
+ held = new Map();
189
+ for (const client of server.clients) client.terminate();
190
+ return new Promise<void>((resolve) => server.close(() => resolve()));
191
+ },
192
+ };
193
+ }