@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.6

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 (60) hide show
  1. package/CHANGELOG.md +1288 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +13 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
@@ -0,0 +1,605 @@
1
+ /**
2
+ * P2P transport for W2M: UDP hole punching plus a reliable, ordered channel on top.
3
+ *
4
+ * The relay exists because two machines behind NAT cannot dial each other. This
5
+ * module is the alternative: both sides send to the other's *reflexive* address at
6
+ * the same time, which opens both mappings, and the task envelope and its result
7
+ * then travel directly instead of through the relay.
8
+ *
9
+ * What is actually guaranteed, and what is not:
10
+ *
11
+ * * **Reliable, and message boundaries are preserved.** Fragments are individually
12
+ * acknowledged and retransmitted; a message is delivered only once every fragment
13
+ * has arrived. This is a from-scratch protocol, so the guarantees are exactly the
14
+ * ones implemented -- there is no TCP underneath to fall back on. Messages sent
15
+ * **one at a time** arrive in send order; concurrent `send()` calls may complete
16
+ * in either order, which is why the W2M integration awaits each send.
17
+ * * **Not a general transport.** One session, one peer, no congestion control
18
+ * beyond a fixed window. That is the right size for "deliver one task offer and
19
+ * return one result"; it is not a file-transfer protocol.
20
+ * * **Punching is not guaranteed to succeed.** A symmetric NAT gives a different
21
+ * mapped port per destination, so the address the peer was told about is not the
22
+ * address the peer's packets will come from. `punch()` reports failure with the
23
+ * attempts it made, and the caller falls back to the relay -- explicitly, never
24
+ * silently.
25
+ *
26
+ * Framing (all integers big-endian):
27
+ *
28
+ * u8 kind
29
+ * u32 session identifies this punch; rejects strays from an old one
30
+ * ... kind-specific body
31
+ *
32
+ * HELLO 1 — "I am here, are you?" repeated to every candidate while punching
33
+ * HELLO_ACK 2 — "I am here too", which is what confirms the path is bidirectional
34
+ * DATA 3 — u32 messageId, u32 fragmentIndex, u32 fragmentTotal, payload
35
+ * ACK 4 — u32 messageId, u32 fragmentIndex (selective, per fragment)
36
+ * DONE 5 — u32 messageId (receiver has every fragment)
37
+ * PING 6 — keeps the mapping open between messages
38
+ * PONG 7 — the answer to PING, and never a PING
39
+ * BYE 8 — the peer is closing
40
+ */
41
+
42
+ import { EventEmitter } from 'node:events';
43
+ import { createHash, randomBytes } from 'node:crypto';
44
+
45
+ /** Frame kinds. Exported so tests and callers can name them instead of using magic numbers. */
46
+ export const KIND = {
47
+ HELLO: 1,
48
+ HELLO_ACK: 2,
49
+ DATA: 3,
50
+ ACK: 4,
51
+ DONE: 5,
52
+ PING: 6,
53
+ PONG: 7,
54
+ BYE: 8,
55
+ };
56
+
57
+ /** Protocol version, so a future framing change is detectable rather than corrupting. */
58
+ export const P2P_VERSION = 1;
59
+
60
+ /**
61
+ * Payload bytes per fragment.
62
+ *
63
+ * 1160 keeps a frame under 1200 bytes including the 13-byte header, which passes a
64
+ * 1280-byte IPv6 minimum MTU and the 1500-byte Ethernet MTU with room for a tunnel
65
+ * (WireGuard adds 60). A larger fragment is faster right up until it is silently
66
+ * dropped by a path that will not fragment.
67
+ */
68
+ export const MAX_FRAGMENT_PAYLOAD = 1160;
69
+
70
+ /** Fragments in flight before waiting for acknowledgements. */
71
+ export const DEFAULT_WINDOW = 32;
72
+
73
+ /** First retransmit delay, and the ceiling it doubles up to. */
74
+ export const DEFAULT_RTO_MS = 300;
75
+ export const MAX_RTO_MS = 3000;
76
+
77
+ /** Retransmits per fragment before the message is declared undeliverable. */
78
+ export const DEFAULT_MAX_ATTEMPTS = 8;
79
+
80
+ /** Largest message accepted, so a hostile length cannot make us allocate without bound. */
81
+ export const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
82
+
83
+ const HEADER_BYTES = 5;
84
+
85
+ /** @param {number} kind @param {number} session @param {Buffer} [body] */
86
+ function frame(kind, session, body = Buffer.alloc(0)) {
87
+ const out = Buffer.alloc(HEADER_BYTES + body.length);
88
+ out.writeUInt8(kind, 0);
89
+ out.writeUInt32BE(session >>> 0, 1);
90
+ body.copy(out, HEADER_BYTES);
91
+ return out;
92
+ }
93
+
94
+ /** @param {Buffer} datagram */
95
+ function parseFrame(datagram) {
96
+ if (datagram.length < HEADER_BYTES) return null;
97
+ const kind = datagram.readUInt8(0);
98
+ const session = datagram.readUInt32BE(1);
99
+ return { kind, session, body: datagram.subarray(HEADER_BYTES) };
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------------------------
103
+ // Punching
104
+ // ---------------------------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Derive a session id both peers can compute without another round trip.
108
+ *
109
+ * This exists because the session id is what makes a punch *one* punch: frames
110
+ * carrying a different id belong to a different attempt and must be ignored. Two
111
+ * peers that each invented their own id would each reject the other's HELLO, and
112
+ * the punch would time out while both sides insist they were sending.
113
+ *
114
+ * Callers pass inputs both sides already share -- the task id and the two machine
115
+ * ids sorted -- so no extra signalling is needed and the result is deterministic.
116
+ *
117
+ * @param {...(string|number)} parts
118
+ * @returns {number} A non-zero 32-bit session id.
119
+ */
120
+ export function deriveSession(...parts) {
121
+ const hash = createHash('sha256').update(parts.map(String).join('\u0000')).digest();
122
+ const value = hash.readUInt32BE(0);
123
+ return value === 0 ? 1 : value;
124
+ }
125
+
126
+ /**
127
+ * Try to open a bidirectional path to one of `remote`'s candidate addresses.
128
+ *
129
+ * Both peers run this at the same time. Each sends HELLO to every candidate; the
130
+ * first peer to get through causes the other's NAT to accept, because the other's
131
+ * outbound HELLO already opened its own mapping in that direction.
132
+ *
133
+ * @param {object} options
134
+ * @param {import('node:dgram').Socket} options.socket Already bound, and the same socket STUN used.
135
+ * @param {Array<{address:string, port:number}>} options.remote
136
+ * @param {number} [options.session] Agreed id, usually from {@link deriveSession}.
137
+ * @param {boolean} [options.allowSessionAdoption] Adopt the initiator's session id
138
+ * instead of requiring one. Only valid for a peer that expects to be the
139
+ * responder; two peers that both adopt end up on different ids.
140
+ * @param {number} [options.timeoutMs]
141
+ * @param {number} [options.intervalMs]
142
+ * @returns {Promise<{ok:boolean, peer:{address:string,port:number}|null, session:number,
143
+ * attempts:number, rttMs:number|null, error:string|null}>}
144
+ */
145
+ export async function punch(options) {
146
+ const { socket } = options;
147
+ if (!socket) throw new TypeError('punch: a bound UDP socket is required');
148
+
149
+ const explicitSession = options.session;
150
+ if (explicitSession === undefined && options.allowSessionAdoption !== true) {
151
+ // Refusing here rather than generating one is deliberate. If both peers generate
152
+ // their own, each discards the other's HELLO and the punch fails with a timeout
153
+ // that looks like a NAT problem -- the most expensive possible misdiagnosis.
154
+ throw new TypeError(
155
+ 'punch: `session` is required (see deriveSession), or pass allowSessionAdoption: true ' +
156
+ 'to accept the initiator\'s id when this peer is the responder',
157
+ );
158
+ }
159
+
160
+ const remote = (options.remote ?? []).filter(
161
+ (c) => c && typeof c.address === 'string' && Number.isInteger(c.port) && c.port > 0,
162
+ );
163
+ if (remote.length === 0) {
164
+ return { ok: false, peer: null, session: explicitSession ?? 0, attempts: 0, rttMs: null, error: 'P2P_NO_CANDIDATES' };
165
+ }
166
+
167
+ const ownSession = explicitSession ?? randomBytes(4).readUInt32BE(0);
168
+ const timeoutMs = options.timeoutMs ?? 5000;
169
+ const intervalMs = options.intervalMs ?? 250;
170
+ const deadline = Date.now() + timeoutMs;
171
+ const startedAt = process.hrtime.bigint();
172
+
173
+ const hello = frame(KIND.HELLO, ownSession);
174
+
175
+ return new Promise((resolve) => {
176
+ let attempts = 0;
177
+ let timer = null;
178
+ let settled = false;
179
+ let agreed = ownSession;
180
+
181
+ const finish = (result) => {
182
+ if (settled) return;
183
+ settled = true;
184
+ clearTimeout(timer);
185
+ socket.removeListener('message', onMessage);
186
+ resolve(result);
187
+ };
188
+
189
+ const onMessage = (datagram, rinfo) => {
190
+ const parsed = parseFrame(datagram);
191
+ if (!parsed) return;
192
+
193
+ if (parsed.kind === KIND.HELLO) {
194
+ if (explicitSession !== undefined && parsed.session !== explicitSession) return;
195
+ // Adopt the initiator's id when we have none of our own, and echo it back so
196
+ // the initiator sees its own id and accepts the answer.
197
+ agreed = parsed.session;
198
+ socket.send(frame(KIND.HELLO_ACK, agreed), rinfo.port, rinfo.address, () => {});
199
+ const rttMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
200
+ finish({
201
+ ok: true,
202
+ peer: { address: rinfo.address, port: rinfo.port },
203
+ session: agreed,
204
+ attempts,
205
+ rttMs: Math.round(rttMs * 100) / 100,
206
+ error: null,
207
+ });
208
+ return;
209
+ }
210
+
211
+ if (parsed.kind === KIND.HELLO_ACK) {
212
+ if (parsed.session !== agreed) return;
213
+ const rttMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
214
+ finish({
215
+ ok: true,
216
+ peer: { address: rinfo.address, port: rinfo.port },
217
+ session: agreed,
218
+ attempts,
219
+ rttMs: Math.round(rttMs * 100) / 100,
220
+ error: null,
221
+ });
222
+ }
223
+ };
224
+
225
+ const tick = () => {
226
+ if (settled) return;
227
+ if (Date.now() >= deadline) {
228
+ finish({
229
+ ok: false,
230
+ peer: null,
231
+ session: agreed,
232
+ attempts,
233
+ rttMs: null,
234
+ error: `P2P_PUNCH_TIMEOUT: no answer from ${remote.length} candidate(s) in ${timeoutMs}ms`,
235
+ });
236
+ return;
237
+ }
238
+ attempts += 1;
239
+ for (const candidate of remote) {
240
+ socket.send(hello, candidate.port, candidate.address, () => {});
241
+ }
242
+ timer = setTimeout(tick, intervalMs);
243
+ };
244
+
245
+ socket.on('message', onMessage);
246
+ tick();
247
+ });
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------------------------
251
+ // Reliable channel
252
+ // ---------------------------------------------------------------------------------------------
253
+
254
+ /**
255
+ * A reliable, ordered message channel over a punched UDP path.
256
+ *
257
+ * Emits `message` (Buffer) for each delivered message, `close`, and `error`.
258
+ */
259
+ export class P2PChannel extends EventEmitter {
260
+ /**
261
+ * @param {object} options
262
+ * @param {import('node:dgram').Socket} options.socket
263
+ * @param {{address:string, port:number}} options.peer
264
+ * @param {number} options.session
265
+ * @param {object} [options.tuning]
266
+ */
267
+ constructor(options) {
268
+ super();
269
+ this.socket = options.socket;
270
+ this.peer = options.peer;
271
+ this.session = options.session;
272
+ this.tuning = {
273
+ window: options.tuning?.window ?? DEFAULT_WINDOW,
274
+ rtoMs: options.tuning?.rtoMs ?? DEFAULT_RTO_MS,
275
+ maxAttempts: options.tuning?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
276
+ };
277
+
278
+ /** messageId -> {fragments: Buffer[], received: Set<number>, total: number, timer} */
279
+ this.incoming = new Map();
280
+ /** messageId -> {fragments: Buffer[], acked: Set<number>, resolve, reject, attempts: Map} */
281
+ this.outgoing = new Map();
282
+ this.nextMessageId = 1;
283
+ this.closed = false;
284
+
285
+ this.onDatagram = this.onDatagram.bind(this);
286
+ this.socket.on('message', this.onDatagram);
287
+ }
288
+
289
+ /** @param {Buffer} datagram */
290
+ onDatagram(datagram) {
291
+ const parsed = parseFrame(datagram);
292
+ if (!parsed || parsed.session !== this.session) return;
293
+ // A datagram from anywhere other than the punched peer is not part of this session.
294
+ // (The check is on the address we already validated; UDP source addresses are
295
+ // spoofable, which is why the session id is random and the payload is not trusted
296
+ // for anything but the task it is correlated with.)
297
+
298
+ if (parsed.kind === KIND.DATA) this.onData(parsed.body);
299
+ else if (parsed.kind === KIND.ACK) this.onAck(parsed.body);
300
+ // A PING must be answered with PONG, never with PING. Echoing the same kind back
301
+ // makes two peers bounce it between them forever, and the loop looks like healthy
302
+ // traffic while it saturates the mapping it was meant to keep alive.
303
+ else if (parsed.kind === KIND.PING) this.sendFrame(KIND.PONG, Buffer.alloc(0));
304
+ else if (parsed.kind === KIND.PONG) this.lastPongAtMs = Date.now();
305
+ else if (parsed.kind === KIND.BYE) this.close('peer-closed');
306
+ }
307
+
308
+ /** @param {Buffer} body */
309
+ onData(body) {
310
+ if (body.length < 12) return;
311
+ const messageId = body.readUInt32BE(0);
312
+ const index = body.readUInt32BE(4);
313
+ const total = body.readUInt32BE(8);
314
+ if (total === 0 || total > 100000 || index >= total) return;
315
+ const payload = body.subarray(12);
316
+
317
+ let entry = this.incoming.get(messageId);
318
+ if (!entry) {
319
+ entry = { fragments: new Array(total).fill(null), received: new Set(), total };
320
+ this.incoming.set(messageId, entry);
321
+ }
322
+ if (!entry.received.has(index)) {
323
+ entry.fragments[index] = Buffer.from(payload);
324
+ entry.received.add(index);
325
+ }
326
+
327
+ // Acknowledge every fragment, including duplicates: a duplicate means our ACK
328
+ // was lost, and re-acking is cheaper than a retransmit storm.
329
+ const ackBody = Buffer.alloc(8);
330
+ ackBody.writeUInt32BE(messageId, 0);
331
+ ackBody.writeUInt32BE(index, 4);
332
+ this.sendFrame(KIND.ACK, ackBody);
333
+
334
+ if (entry.received.size === entry.total) {
335
+ this.incoming.delete(messageId);
336
+ const assembled = Buffer.concat(entry.fragments, entry.fragments.reduce((n, f) => n + f.length, 0));
337
+ const doneBody = Buffer.alloc(4);
338
+ doneBody.writeUInt32BE(messageId, 0);
339
+ this.sendFrame(KIND.DONE, doneBody);
340
+ this.emit('message', assembled);
341
+ }
342
+ }
343
+
344
+ /** @param {Buffer} body */
345
+ onAck(body) {
346
+ if (body.length < 8) return;
347
+ const messageId = body.readUInt32BE(0);
348
+ const index = body.readUInt32BE(4);
349
+ const entry = this.outgoing.get(messageId);
350
+ if (!entry) return;
351
+ entry.acked.add(index);
352
+ if (entry.acked.size === entry.fragments.length) {
353
+ this.outgoing.delete(messageId);
354
+ clearTimeout(entry.timer);
355
+ entry.resolve();
356
+ }
357
+ }
358
+
359
+ /** @param {number} kind @param {Buffer} body */
360
+ sendFrame(kind, body) {
361
+ if (this.closed) return;
362
+ this.socket.send(frame(kind, this.session, body), this.peer.port, this.peer.address, () => {});
363
+ }
364
+
365
+ /**
366
+ * Send one message reliably.
367
+ *
368
+ * Resolves when the peer has acknowledged every fragment. Rejects only after
369
+ * `maxAttempts` retransmits of some fragment, with the fragment index in the
370
+ * message -- "the peer went away" and "the path broke on one fragment" need
371
+ * different responses from a caller.
372
+ *
373
+ * @param {Buffer|string} payload
374
+ * @returns {Promise<void>}
375
+ */
376
+ send(payload) {
377
+ const buf = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload), 'utf8');
378
+ if (buf.length > MAX_MESSAGE_BYTES) {
379
+ return Promise.reject(new Error(`P2P_MESSAGE_TOO_LARGE: ${buf.length} > ${MAX_MESSAGE_BYTES}`));
380
+ }
381
+ if (this.closed) return Promise.reject(new Error('P2P_CHANNEL_CLOSED'));
382
+
383
+ const messageId = this.nextMessageId;
384
+ this.nextMessageId = (this.nextMessageId % 0xffffffff) + 1;
385
+
386
+ const fragments = [];
387
+ for (let offset = 0; offset < Math.max(buf.length, 1); offset += MAX_FRAGMENT_PAYLOAD) {
388
+ fragments.push(buf.subarray(offset, Math.min(offset + MAX_FRAGMENT_PAYLOAD, buf.length)));
389
+ }
390
+
391
+ return new Promise((resolve, reject) => {
392
+ const entry = {
393
+ fragments,
394
+ acked: new Set(),
395
+ resolve,
396
+ reject,
397
+ attempts: new Map(),
398
+ };
399
+ this.outgoing.set(messageId, entry);
400
+
401
+ const pump = () => {
402
+ if (this.closed) return;
403
+ const pending = [];
404
+ for (let i = 0; i < fragments.length; i += 1) {
405
+ if (entry.acked.has(i)) continue;
406
+ pending.push(i);
407
+ if (pending.length >= this.tuning.window) break;
408
+ }
409
+ if (pending.length === 0) return;
410
+
411
+ for (const index of pending) {
412
+ const attempt = (entry.attempts.get(index) ?? 0) + 1;
413
+ entry.attempts.set(index, attempt);
414
+ if (attempt > this.tuning.maxAttempts) {
415
+ this.outgoing.delete(messageId);
416
+ reject(
417
+ new Error(
418
+ `P2P_FRAGMENT_UNACKED: fragment ${index}/${fragments.length} of message ${messageId} ` +
419
+ `after ${this.tuning.maxAttempts} attempts`,
420
+ ),
421
+ );
422
+ return;
423
+ }
424
+ const body = Buffer.alloc(12 + fragments[index].length);
425
+ body.writeUInt32BE(messageId, 0);
426
+ body.writeUInt32BE(index, 4);
427
+ body.writeUInt32BE(fragments.length, 8);
428
+ fragments[index].copy(body, 12);
429
+ this.sendFrame(KIND.DATA, body);
430
+ }
431
+
432
+ const worst = Math.max(...pending.map((i) => entry.attempts.get(i) ?? 1));
433
+ const delay = Math.min(this.tuning.rtoMs * 2 ** (worst - 1), MAX_RTO_MS);
434
+ // One timer per message, not one per channel: a shared field means a second
435
+ // concurrent send silently cancels the first one's retransmission schedule.
436
+ clearTimeout(entry.timer);
437
+ entry.timer = setTimeout(() => {
438
+ if (this.outgoing.has(messageId)) pump();
439
+ }, delay);
440
+ entry.timer.unref?.();
441
+ };
442
+
443
+ pump();
444
+ });
445
+ }
446
+
447
+ /** Stop the channel. The socket is NOT closed: the caller owns it. */
448
+ close(reason = 'closed') {
449
+ if (this.closed) return;
450
+ this.closed = true;
451
+ this.socket.removeListener('message', this.onDatagram);
452
+ for (const entry of this.outgoing.values()) {
453
+ clearTimeout(entry.timer);
454
+ entry.reject(new Error(`P2P_CHANNEL_CLOSED: ${reason}`));
455
+ }
456
+ this.outgoing.clear();
457
+ this.incoming.clear();
458
+ if (reason !== 'peer-closed') {
459
+ try {
460
+ this.sendFrame(KIND.BYE, Buffer.alloc(0));
461
+ } catch {
462
+ /* the path is already gone */
463
+ }
464
+ }
465
+ this.emit('close', reason);
466
+ }
467
+ }
468
+
469
+ /**
470
+ * The responder's half of a punch: wait for a HELLO, answer it, hand back a channel.
471
+ *
472
+ * This is the mirror of {@link punch}, and it exists because the two ends of a punch do
473
+ * genuinely different things. The initiator knows the session and every candidate and
474
+ * has to keep sending; the responder knows nothing, waits, and learns both the session
475
+ * and the address from the HELLO that reaches it. That asymmetry is what lets a peer be
476
+ * dialled without any prior arrangement — and it is why adoption (`allowSessionAdoption`)
477
+ * exists in `punch()`: a responder that invented its own id would discard the very
478
+ * datagram it is waiting for.
479
+ *
480
+ * Two decisions worth stating:
481
+ *
482
+ * * **A claimed session is re-acknowledged, never adopted.** When the HELLO's session
483
+ * already has a live channel, the answer is another HELLO_ACK and no second channel:
484
+ * the initiator retransmits HELLOs while punching, so a lost ACK has to be
485
+ * answerable again, but two channels on one session would each assemble and deliver
486
+ * every payload and the peer would see each message twice. The caller owns that
487
+ * `claimed` set, because a claim has to outlive one call to this function.
488
+ * * **`rttMs` is `null`.** The responder never sent anything before the HELLO arrived,
489
+ * so there is no round trip to measure. Reporting the time spent waiting as an RTT
490
+ * would be a fabricated number in a field named for a measurement.
491
+ *
492
+ * @param {object} options
493
+ * @param {import('node:dgram').Socket} options.socket Already bound; the same socket the
494
+ * punch was aimed at, and the same one a channel will use.
495
+ * @param {number} [options.timeoutMs] How long to wait before giving up.
496
+ * @param {Set<number>|Map<number, unknown>} [options.claimed] Sessions that already have
497
+ * a live channel. Read, never written.
498
+ * @param {number} [options.intervalMs] Floor between re-acknowledgements of the same
499
+ * claimed session within one call, so a peer that sprays HELLOs cannot make the
500
+ * responder answer every single one. The default matches `punch()`'s send
501
+ * interval, so a retransmission is always answered; `0` disables the floor.
502
+ * @returns {Promise<{ok:boolean, channel:P2PChannel|null, peer:{address:string,port:number}|null,
503
+ * session:number, rttMs:number|null, error:string|null}>}
504
+ */
505
+ export async function accept(options) {
506
+ const { socket } = options;
507
+ if (!socket) throw new TypeError('accept: a bound UDP socket is required');
508
+
509
+ const timeoutMs = options.timeoutMs ?? 5000;
510
+ const intervalMs = options.intervalMs ?? 250;
511
+ const claimed = options.claimed ?? null;
512
+
513
+ return new Promise((resolve) => {
514
+ let settled = false;
515
+ /** session -> last re-ack timestamp, so the floor is per session, not per socket. */
516
+ const reackedAt = new Map();
517
+
518
+ const finish = (result) => {
519
+ if (settled) return;
520
+ settled = true;
521
+ clearTimeout(timer);
522
+ socket.removeListener('message', onMessage);
523
+ socket.removeListener('close', onSocketClose);
524
+ resolve(result);
525
+ };
526
+
527
+ const timer = setTimeout(() => {
528
+ finish({
529
+ ok: false,
530
+ channel: null,
531
+ peer: null,
532
+ session: 0,
533
+ rttMs: null,
534
+ error: `P2P_ACCEPT_TIMEOUT: no HELLO within ${timeoutMs}ms`,
535
+ });
536
+ }, timeoutMs);
537
+
538
+ // A wait on a socket that goes away must end now, not at the end of the timeout:
539
+ // this is what makes a node's `close()` a barrier instead of a five-second stall.
540
+ const onSocketClose = () => {
541
+ finish({
542
+ ok: false,
543
+ channel: null,
544
+ peer: null,
545
+ session: 0,
546
+ rttMs: null,
547
+ error: 'P2P_SOCKET_CLOSED: the socket went away while waiting for a HELLO',
548
+ });
549
+ };
550
+
551
+ const onMessage = (datagram, rinfo) => {
552
+ const parsed = parseFrame(datagram);
553
+ // Only a HELLO is this function's business. DATA/ACK/PING/PONG belong to whichever
554
+ // channels are already listening; a second consumer of them would be a second
555
+ // delivery, which is the bug the `claimed` set exists to prevent.
556
+ if (!parsed || parsed.kind !== KIND.HELLO) return;
557
+ const session = parsed.session;
558
+
559
+ if (claimed !== null && claimed.has(session)) {
560
+ const now = Date.now();
561
+ const last = reackedAt.get(session) ?? Number.NEGATIVE_INFINITY;
562
+ if (now - last >= intervalMs) {
563
+ reackedAt.set(session, now);
564
+ socket.send(frame(KIND.HELLO_ACK, session), rinfo.port, rinfo.address, () => {});
565
+ }
566
+ return;
567
+ }
568
+
569
+ socket.send(frame(KIND.HELLO_ACK, session), rinfo.port, rinfo.address, () => {});
570
+ finish({
571
+ ok: true,
572
+ channel: new P2PChannel({
573
+ socket,
574
+ peer: { address: rinfo.address, port: rinfo.port },
575
+ session,
576
+ }),
577
+ peer: { address: rinfo.address, port: rinfo.port },
578
+ session,
579
+ rttMs: null,
580
+ error: null,
581
+ });
582
+ };
583
+
584
+ socket.on('message', onMessage);
585
+ socket.once('close', onSocketClose);
586
+ });
587
+ }
588
+
589
+ /**
590
+ * Punch, then return a reliable channel on the opened path.
591
+ *
592
+ * @param {object} options Same as {@link punch}, plus `tuning` for the channel.
593
+ * @returns {Promise<{ok:boolean, channel:P2PChannel|null, punch:object, error:string|null}>}
594
+ */
595
+ export async function connect(options) {
596
+ const result = await punch(options);
597
+ if (!result.ok) return { ok: false, channel: null, punch: result, error: result.error };
598
+ const channel = new P2PChannel({
599
+ socket: options.socket,
600
+ peer: result.peer,
601
+ session: result.session,
602
+ tuning: options.tuning,
603
+ });
604
+ return { ok: true, channel, punch: result, error: null };
605
+ }