@feralfile/cli 3.0.0 → 4.0.0

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 (45) hide show
  1. package/README.md +20 -17
  2. package/config.json.example +1 -1
  3. package/dist/index.js +10 -2
  4. package/dist/package.json +5 -2
  5. package/dist/src/commands/build.js +1 -1
  6. package/dist/src/commands/device.js +84 -39
  7. package/dist/src/commands/enrich.js +2 -2
  8. package/dist/src/commands/find.js +71 -37
  9. package/dist/src/commands/helpers/device-control.js +937 -0
  10. package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
  11. package/dist/src/commands/helpers/playlist-display.js +2 -15
  12. package/dist/src/commands/play.js +39 -26
  13. package/dist/src/commands/report.js +251 -0
  14. package/dist/src/commands/setup.js +1 -1
  15. package/dist/src/config.js +162 -6
  16. package/dist/src/utilities/control/codec.js +578 -0
  17. package/dist/src/utilities/control/control-errors.js +157 -0
  18. package/dist/src/utilities/control/controller-store.js +964 -0
  19. package/dist/src/utilities/control/jwk.js +141 -0
  20. package/dist/src/utilities/control/pairing-channel.js +52 -0
  21. package/dist/src/utilities/control/pairing.js +416 -0
  22. package/dist/src/utilities/control/registration.js +391 -0
  23. package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
  24. package/dist/src/utilities/control/secure-transport.js +850 -0
  25. package/dist/src/utilities/control/strict-json.js +376 -0
  26. package/dist/src/utilities/device-network.js +129 -0
  27. package/dist/src/utilities/device-upsert.js +1 -1
  28. package/dist/src/utilities/ff1-compatibility.js +87 -11
  29. package/dist/src/utilities/ff1-device.js +161 -78
  30. package/dist/src/utilities/ff1-relayer.js +34 -14
  31. package/dist/src/utilities/handoff-client.js +23 -9
  32. package/dist/src/utilities/neort-marketplace.js +3 -3
  33. package/dist/src/utilities/playlist-builder.js +3 -3
  34. package/dist/src/utilities/playlist-cast.js +6 -6
  35. package/dist/src/utilities/playlist-source.js +7 -7
  36. package/dist/src/utilities/raster-client.js +1 -1
  37. package/dist/src/utilities/relay-client.js +774 -0
  38. package/dist/src/utilities/ssh-access.js +192 -58
  39. package/dist/src/utilities/support-report.js +511 -0
  40. package/dist/src/utilities/temporary-channel.js +139 -0
  41. package/docs/CONFIGURATION.md +270 -28
  42. package/docs/EXAMPLES.md +31 -31
  43. package/docs/PROJECT_SPEC.md +32 -19
  44. package/docs/README.md +113 -36
  45. package/package.json +5 -2
@@ -0,0 +1,774 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RelayClient = exports.RelayAmbiguousError = exports.RelayUndeliveredError = exports.PING_INTERVAL_MS = void 0;
4
+ exports.relayURL = relayURL;
5
+ exports.encodeRelayFrame = encodeRelayFrame;
6
+ exports.decodeRelayFrame = decodeRelayFrame;
7
+ exports.startPinging = startPinging;
8
+ /** Maximum reconstructed command size, matching the FF1 command ingress. */
9
+ const MAX_MESSAGE = 4 * 1024 * 1024;
10
+ /**
11
+ * MAX_ASSEMBLY_BYTES bounds every unfinished inbound transfer together. Until
12
+ * a peer is pinned any co-channel peer may start one, so a per-peer bound
13
+ * alone would let 35 strangers make the CLI hold 35 × 4 MiB.
14
+ */
15
+ const MAX_ASSEMBLY_BYTES = MAX_MESSAGE;
16
+ const ASSEMBLY_TIMEOUT_MS = 180000;
17
+ const CHUNK_SIZE = 32768;
18
+ /** RECEIPT_TIMEOUT_MS is how long one frame waits for its relay receipts. */
19
+ const RECEIPT_TIMEOUT_MS = 5000;
20
+ /**
21
+ * FANOUT_GRACE_MS is how long a fan-out frame keeps waiting for the other
22
+ * peers once one peer has acknowledged it. A silent stranger on the public
23
+ * channel then costs about a second per chunk, not the whole receipt timeout.
24
+ */
25
+ const FANOUT_GRACE_MS = 1000;
26
+ /**
27
+ * CONNECT_ATTEMPTS bounds reconnects before the first `hello`. §10 calls a
28
+ * connect-time refusal (HTTP 429, close 1008) transient; a browser-style
29
+ * WebSocket cannot tell it from a 404, so the attempts are bounded and the
30
+ * command is reported undelivered (the next command tries again).
31
+ */
32
+ const CONNECT_ATTEMPTS = 3;
33
+ const BACKOFF_BASE_MS = 250;
34
+ const BACKOFF_MAX_MS = 4000;
35
+ /**
36
+ * PING_INTERVAL_MS keeps the socket alive (§10 Liveness): the relay closes a
37
+ * connection that shows no frame and no `ping` for 90 s, and every endpoint
38
+ * pings at least every 30 s.
39
+ */
40
+ exports.PING_INTERVAL_MS = 25_000;
41
+ const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
42
+ /** MESSAGE_ID_PATTERN is the shape of the messageID this client issues. */
43
+ const MESSAGE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
44
+ const encoder = new TextEncoder();
45
+ const decoder = new TextDecoder('utf-8', { fatal: true });
46
+ /**
47
+ * RelayUndeliveredError means the request provably never reached a peer: it
48
+ * was never sent, or the relay refused it for every target and no chunk of
49
+ * it was acknowledged (ff1-control-v1 §10). Only this error allows a resend
50
+ * or a fallback to another route.
51
+ */
52
+ class RelayUndeliveredError extends Error {
53
+ constructor(message) {
54
+ super(message);
55
+ this.name = 'RelayUndeliveredError';
56
+ }
57
+ }
58
+ exports.RelayUndeliveredError = RelayUndeliveredError;
59
+ /**
60
+ * RelayAmbiguousError means some part of the request may have reached a peer
61
+ * (an acknowledgement, a missing acknowledgement, a socket drop after a
62
+ * send). The FF1 may have run it, so it is never resent (§14.4).
63
+ */
64
+ class RelayAmbiguousError extends Error {
65
+ constructor(message) {
66
+ super(message);
67
+ this.name = 'RelayAmbiguousError';
68
+ }
69
+ }
70
+ exports.RelayAmbiguousError = RelayAmbiguousError;
71
+ /** relayURL selects the public device channel without credentials. */
72
+ function relayURL(baseUrl, deviceId) {
73
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(deviceId)) {
74
+ throw new Error('Invalid device ID');
75
+ }
76
+ const url = new URL(baseUrl);
77
+ if (!['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol) ||
78
+ url.username ||
79
+ url.password ||
80
+ url.search ||
81
+ url.hash ||
82
+ !['', '/'].includes(url.pathname)) {
83
+ throw new Error('Invalid relayer endpoint');
84
+ }
85
+ url.protocol = ['https:', 'wss:'].includes(url.protocol) ? 'wss:' : 'ws:';
86
+ url.pathname = `/v2/channels/${deviceId}/connections`;
87
+ return url.toString();
88
+ }
89
+ /** encodeRelayFrame leaves the application payload byte-for-byte intact. */
90
+ function encodeRelayFrame(header, payload) {
91
+ const encoded = encoder.encode(JSON.stringify(header));
92
+ if (encoded.length > 1024 || encoded.length + payload.length + 2 > 65536) {
93
+ throw new Error('Relay frame too large');
94
+ }
95
+ const frame = new Uint8Array(encoded.length + payload.length + 2);
96
+ new DataView(frame.buffer).setUint16(0, encoded.length);
97
+ frame.set(encoded, 2);
98
+ frame.set(payload, encoded.length + 2);
99
+ return frame;
100
+ }
101
+ /** decodeRelayFrame validates routing metadata before delivering application data. */
102
+ function decodeRelayFrame(frame) {
103
+ if (frame.byteLength < 3 || frame.byteLength > 65536) {
104
+ throw new Error('Invalid relay frame');
105
+ }
106
+ const size = new DataView(frame).getUint16(0);
107
+ if (!size || size > 1024 || size + 2 > frame.byteLength) {
108
+ throw new Error('Invalid relay header');
109
+ }
110
+ const header = JSON.parse(decoder.decode(new Uint8Array(frame, 2, size)));
111
+ if (header.v !== 2 ||
112
+ ![header.id, header.to, header.from].every((value) => typeof value === 'string' && ID_PATTERN.test(value))) {
113
+ throw new Error('Invalid relay routing');
114
+ }
115
+ return { header, payload: new Uint8Array(frame, size + 2) };
116
+ }
117
+ /**
118
+ * RelayClient carries protected JSON commands over relay v2 (§10).
119
+ *
120
+ * The first send of a request fans out to `to:"peers"`, and the client pins
121
+ * the peer whose reply the caller's validator accepts (the reply verifies
122
+ * under the enrolled profile). Peers that join later, or that the relay
123
+ * refused transiently, get the same sealed bytes by unicast; a peer that may
124
+ * already hold them never gets them twice. Failures are typed: see
125
+ * RelayUndeliveredError and RelayAmbiguousError. Receipt ACKs never imply
126
+ * application acceptance.
127
+ */
128
+ class RelayClient {
129
+ socketFactory;
130
+ nextFrameAt = 0;
131
+ frameWrites = Promise.resolve();
132
+ socket;
133
+ url;
134
+ connected = false;
135
+ retryTimer;
136
+ player;
137
+ peers = new Set();
138
+ pending = new Map();
139
+ receipts = new Map();
140
+ /** Frame ID to its request, for ACKs that arrive after the receipt finished. */
141
+ frameOwners = new Map();
142
+ pinger;
143
+ validators = new Map();
144
+ assemblies = new Map();
145
+ closed = false;
146
+ sequence = Promise.resolve();
147
+ readiness;
148
+ ready;
149
+ /** Create a controller connection; requests wait for the relay's hello. */
150
+ constructor(baseUrl, deviceId, socketFactory = (url) => new WebSocket(url, 'ff-relay.v2')) {
151
+ this.socketFactory = socketFactory;
152
+ this.ready = new Promise((resolve, reject) => {
153
+ this.readiness = { resolve: () => resolve(), reject };
154
+ });
155
+ // Connection errors may precede the first request; retain rejection without
156
+ // producing an unhandled promise while the caller prepares its command.
157
+ void this.ready.catch(() => undefined);
158
+ this.url = relayURL(baseUrl, deviceId);
159
+ this.connect(0);
160
+ }
161
+ /**
162
+ * requestPrepared seals only after the connection is ready, sends the
163
+ * sealed command and resolves with the first reply its validator accepts.
164
+ *
165
+ * @param prepare - Seals the command; called at most once
166
+ * @param timeoutMs - Deadline for the whole request, connecting included
167
+ * @returns The verified RPC reply
168
+ * @throws {RelayUndeliveredError} When no part of the command reached a peer
169
+ * @throws {RelayAmbiguousError} When the command may have reached a peer
170
+ */
171
+ async requestPrepared(prepare, timeoutMs = 30000) {
172
+ const id = crypto.randomUUID();
173
+ const delivery = { written: 0, refused: 0, reached: new Set(), frames: new Set() };
174
+ // Bound queue/open waits before allocating a replay counter or sealing.
175
+ const result = this.wait(`rpc:${id}`, timeoutMs);
176
+ void result.catch(() => undefined);
177
+ const work = this.sequence.then(() => this.run(id, prepare, result, delivery));
178
+ this.sequence = work.catch(() => undefined);
179
+ void work.catch((error) => this.settle(`rpc:${id}`, undefined, error));
180
+ try {
181
+ return (await result);
182
+ }
183
+ catch (error) {
184
+ throw classify(error, delivery);
185
+ }
186
+ finally {
187
+ this.validators.delete(id);
188
+ delivery.frames.forEach((frame) => this.frameOwners.delete(frame));
189
+ }
190
+ }
191
+ /** close releases the socket and all pending calls, including readiness. */
192
+ close(error = new Error('Relay client closed')) {
193
+ if (this.closed) {
194
+ return;
195
+ }
196
+ this.closed = true;
197
+ clearTimeout(this.retryTimer);
198
+ clearInterval(this.pinger);
199
+ this.readiness.reject(error);
200
+ for (const receipt of [...this.receipts.values()]) {
201
+ receipt.finish();
202
+ }
203
+ for (const pending of this.pending.values()) {
204
+ pending.reject(error);
205
+ }
206
+ this.pending.clear();
207
+ this.assemblies.clear();
208
+ this.validators.clear();
209
+ this.socket.close();
210
+ }
211
+ /**
212
+ * connect opens one socket. Before the first hello a failure reconnects
213
+ * with backoff (CONNECT_ATTEMPTS); afterwards it closes the client.
214
+ */
215
+ connect(attempt) {
216
+ const socket = this.socketFactory(this.url);
217
+ socket.binaryType = 'arraybuffer';
218
+ this.socket = socket;
219
+ let lostOnce = false;
220
+ const lost = (error) => {
221
+ if (lostOnce || socket !== this.socket || this.closed) {
222
+ return;
223
+ }
224
+ lostOnce = true;
225
+ if (!this.connected && attempt + 1 < CONNECT_ATTEMPTS) {
226
+ socket.close();
227
+ this.retryTimer = setTimeout(() => this.connect(attempt + 1), backoffMs(attempt));
228
+ return;
229
+ }
230
+ this.close(error);
231
+ };
232
+ socket.addEventListener('message', (event) => {
233
+ if (socket !== this.socket) {
234
+ return;
235
+ }
236
+ try {
237
+ this.receive(event.data);
238
+ }
239
+ catch (error) {
240
+ this.close(error);
241
+ }
242
+ });
243
+ socket.addEventListener('error', () => lost(new Error('Relayer connection failed')));
244
+ socket.addEventListener('close', (event) => {
245
+ const code = event.code;
246
+ lost(new Error(code ? `Relayer disconnected (${code})` : 'Relayer disconnected'));
247
+ });
248
+ }
249
+ /** run is one request's work, serialized behind earlier requests. */
250
+ async run(id, prepare, result, delivery) {
251
+ const active = () => this.pending.has(`rpc:${id}`) && !this.closed;
252
+ await Promise.race([this.ready, result]);
253
+ if (!active()) {
254
+ throw new Error('Relay request expired');
255
+ }
256
+ const prepared = await prepare();
257
+ if (!active()) {
258
+ throw new Error('Relay request expired');
259
+ }
260
+ if (prepared.command !== 'ff1Envelope') {
261
+ throw new Error('V2 requires a protected envelope');
262
+ }
263
+ this.validators.set(id, prepared.verify);
264
+ const body = encoder.encode(JSON.stringify({
265
+ messageID: id,
266
+ message: { command: prepared.command, request: prepared.request },
267
+ }));
268
+ await this.deliver(body, delivery, active, result);
269
+ }
270
+ /**
271
+ * deliver sends one sealed body until a reply settles the request: first
272
+ * to the pinned player, or fanned out to `peers` (§10), then by unicast to
273
+ * peers that never received it. It throws RelayUndeliveredError as soon as
274
+ * no peer is left to try and nothing was delivered.
275
+ */
276
+ async deliver(body, delivery, active, result) {
277
+ const states = new Map();
278
+ const retries = new Map();
279
+ const first = await this.send(body, this.player ?? 'peers', active, delivery, states);
280
+ if (first.empty) {
281
+ throw new RelayUndeliveredError('Relay: no peer is connected to this FF1 channel');
282
+ }
283
+ while (active()) {
284
+ const now = Date.now();
285
+ const candidates = (this.player ? [this.player] : [...this.peers]).filter((peer) => {
286
+ const state = states.get(peer);
287
+ if (delivery.reached.has(peer)) {
288
+ // It ACKed a frame of this request, possibly late: never a second copy.
289
+ return false;
290
+ }
291
+ return state === undefined || (state === 'retry' && (retries.get(peer)?.at ?? 0) <= now);
292
+ });
293
+ if (!candidates.length && !this.retryable(states) && !touched(delivery)) {
294
+ throw new RelayUndeliveredError('Relay refused the command for every peer');
295
+ }
296
+ for (const peer of candidates) {
297
+ const attempt = states.get(peer) === 'retry' ? (retries.get(peer)?.attempt ?? 0) + 1 : 0;
298
+ await this.send(body, peer, active, delivery, states);
299
+ retries.set(peer, { attempt, at: Date.now() + backoffMs(attempt) });
300
+ }
301
+ await Promise.race([
302
+ new Promise((resolve) => setTimeout(resolve, 100)),
303
+ result.then(() => undefined, () => undefined),
304
+ ]);
305
+ }
306
+ }
307
+ /** retryable reports a live peer the relay refused only transiently. */
308
+ retryable(states) {
309
+ return [...states].some(([peer, state]) => state === 'retry' && this.peers.has(peer));
310
+ }
311
+ /** wait owns the bounded deadline of one request. */
312
+ wait(key, timeoutMs) {
313
+ return new Promise((resolve, reject) => {
314
+ const timer = setTimeout(() => {
315
+ this.pending.delete(key);
316
+ reject(new Error('Relay response timeout'));
317
+ }, timeoutMs);
318
+ this.pending.set(key, {
319
+ resolve: (value) => {
320
+ clearTimeout(timer);
321
+ resolve(value);
322
+ },
323
+ reject: (error) => {
324
+ clearTimeout(timer);
325
+ reject(error);
326
+ },
327
+ });
328
+ });
329
+ }
330
+ /** settle consumes exactly one matching response or failure. */
331
+ settle(key, value, error) {
332
+ const pending = this.pending.get(key);
333
+ if (!pending) {
334
+ return;
335
+ }
336
+ this.pending.delete(key);
337
+ if (error) {
338
+ pending.reject(error);
339
+ }
340
+ else {
341
+ pending.resolve(value);
342
+ }
343
+ }
344
+ /**
345
+ * send writes one body to `to` (a peer or `peers`) chunk by chunk and
346
+ * records what the receipts proved per peer in `states`. It stops early
347
+ * once no peer holds every chunk so far, since none could reassemble it.
348
+ */
349
+ async send(body, to, active, delivery, states) {
350
+ if (body.length > MAX_MESSAGE) {
351
+ throw new Error('Relay message too large');
352
+ }
353
+ const transfer = crypto.randomUUID();
354
+ const count = Math.ceil(body.length / CHUNK_SIZE);
355
+ const sendStates = new Map();
356
+ for (let index = 0; index < count && active(); index++) {
357
+ const payload = chunkPayload(body, transfer, index, count);
358
+ const receipt = await this.writeFrame(payload, to, delivery, active);
359
+ if (receipt.empty) {
360
+ return { empty: true };
361
+ }
362
+ mergeChunkResult(sendStates, receipt, index === 0);
363
+ if (![...sendStates.values()].includes('full')) {
364
+ break;
365
+ }
366
+ }
367
+ for (const [peer, state] of sendStates) {
368
+ states.set(peer, state);
369
+ }
370
+ return { empty: false };
371
+ }
372
+ /** writeFrame paces one frame below the relay's byte ceiling and awaits its receipts. */
373
+ writeFrame(payload, to, delivery, active) {
374
+ const id = crypto.randomUUID();
375
+ const frame = encodeRelayFrame({ v: 2, id, to }, payload);
376
+ const write = this.frameWrites.then(async () => {
377
+ const delay = this.nextFrameAt - Date.now();
378
+ if (delay > 0) {
379
+ await new Promise((resolve) => setTimeout(resolve, delay));
380
+ }
381
+ this.nextFrameAt = Date.now() + Math.max(20, Math.ceil((frame.byteLength * 1000) / 750000));
382
+ // Re-checked after the pacing wait: a request whose deadline already
383
+ // fired was reported on what had been written, so nothing more may go.
384
+ if (this.closed || !active()) {
385
+ return refusedLocally(to);
386
+ }
387
+ const receipt = this.expectReceipt(id, to, delivery);
388
+ // Counted before the write: from here on only explicit refusals can
389
+ // prove the frame undelivered, whatever happens to the socket.
390
+ delivery.written++;
391
+ delivery.frames.add(id);
392
+ this.frameOwners.set(id, delivery);
393
+ try {
394
+ this.socket.send(frame);
395
+ }
396
+ catch (error) {
397
+ this.close(error);
398
+ }
399
+ return { receipt };
400
+ });
401
+ this.frameWrites = write.then(() => undefined, () => undefined);
402
+ return write.then((written) => ('receipt' in written ? written.receipt : written));
403
+ }
404
+ /**
405
+ * expectReceipt registers the receipts one frame waits for: the target's,
406
+ * or every currently known peer's for a fan-out. The frame counts as
407
+ * refused only when the relay refused it explicitly for every destination
408
+ * and no peer ACKed it; silence, a departure or a close leaves it ambiguous.
409
+ */
410
+ expectReceipt(id, to, delivery) {
411
+ return new Promise((resolve) => {
412
+ const fanout = to === 'peers';
413
+ const result = {
414
+ empty: false,
415
+ acked: new Set(),
416
+ refused: new Map(),
417
+ unanswered: new Set(),
418
+ };
419
+ const receipt = {
420
+ delivery,
421
+ to,
422
+ fanout,
423
+ awaiting: new Set(fanout ? this.peers : [to]),
424
+ result,
425
+ timer: setTimeout(() => receipt.finish(), RECEIPT_TIMEOUT_MS),
426
+ finish: () => {
427
+ clearTimeout(receipt.timer);
428
+ clearTimeout(receipt.grace);
429
+ this.receipts.delete(id);
430
+ receipt.awaiting.forEach((peer) => result.unanswered.add(peer));
431
+ receipt.awaiting.clear();
432
+ const refusedAll = !result.acked.size && !result.unanswered.size && result.refused.size > 0;
433
+ if (result.empty || refusedAll) {
434
+ delivery.refused++;
435
+ }
436
+ resolve(result);
437
+ },
438
+ };
439
+ this.receipts.set(id, receipt);
440
+ });
441
+ }
442
+ /** onReceipt applies one relay ack or routing error to its frame. */
443
+ onReceipt(event) {
444
+ const receipt = typeof event.id === 'string' ? this.receipts.get(event.id) : undefined;
445
+ if (!receipt) {
446
+ // A late ACK still proves the peer holds (part of) the request.
447
+ const owner = typeof event.id === 'string' ? this.frameOwners.get(event.id) : undefined;
448
+ if (owner && event.type === 'ack' && validId(event.from)) {
449
+ owner.reached.add(event.from);
450
+ }
451
+ return;
452
+ }
453
+ const peer = event.type === 'ack' ? event.from : event.to;
454
+ if (!receipt.fanout && peer !== receipt.to) {
455
+ // Only the targeted recipient can answer a unicast frame.
456
+ return;
457
+ }
458
+ if (event.type === 'ack' && typeof event.from === 'string') {
459
+ receipt.result.acked.add(event.from);
460
+ receipt.awaiting.delete(event.from);
461
+ receipt.delivery.reached.add(event.from);
462
+ if (receipt.fanout && receipt.awaiting.size && !receipt.grace) {
463
+ receipt.grace = setTimeout(() => receipt.finish(), FANOUT_GRACE_MS);
464
+ }
465
+ }
466
+ else if (event.type === 'error' && event.to === 'peers') {
467
+ receipt.result.empty = true;
468
+ receipt.awaiting.clear();
469
+ }
470
+ else if (event.type === 'error' && typeof event.to === 'string') {
471
+ receipt.result.refused.set(event.to, String(event.code));
472
+ receipt.awaiting.delete(event.to);
473
+ }
474
+ else {
475
+ return;
476
+ }
477
+ if (!receipt.fanout || !receipt.awaiting.size) {
478
+ receipt.finish();
479
+ }
480
+ }
481
+ /** receive distinguishes relay controls from the existing application envelope. */
482
+ receive(data) {
483
+ if (data === 'pong') {
484
+ return;
485
+ }
486
+ if (typeof data === 'string') {
487
+ this.control(JSON.parse(data));
488
+ return;
489
+ }
490
+ if (!(data instanceof ArrayBuffer)) {
491
+ throw new Error('Invalid relay data');
492
+ }
493
+ const { header, payload } = decodeRelayFrame(data);
494
+ this.socket.send(JSON.stringify({ type: 'ack', from: header.from, id: header.id }));
495
+ const from = header.from;
496
+ if (!this.peers.has(from) || (this.player && from !== this.player)) {
497
+ return;
498
+ }
499
+ let message;
500
+ try {
501
+ message = parseMessage(this.assemble(from, payload));
502
+ }
503
+ catch {
504
+ return;
505
+ }
506
+ if (message?.type === 'RPC' && typeof message.messageID === 'string') {
507
+ this.verifyReply(from, message);
508
+ }
509
+ }
510
+ /**
511
+ * verifyReply settles a request with a reply its validator accepts, and
512
+ * pins that peer. An unverified reply settles nothing: on the public
513
+ * channel a forgery costs availability only.
514
+ */
515
+ verifyReply(from, message) {
516
+ // messageID comes from an untrusted frame and selects a validator, so it
517
+ // must have the shape this client issues (crypto.randomUUID) and must map
518
+ // to a function this client registered in its own Map.
519
+ const messageID = message.messageID;
520
+ if (typeof messageID !== 'string' || !MESSAGE_ID_PATTERN.test(messageID)) {
521
+ return;
522
+ }
523
+ const verify = this.validators.get(messageID);
524
+ if (typeof verify !== 'function') {
525
+ return;
526
+ }
527
+ void verify(message)
528
+ .then((accepted) => {
529
+ if (accepted && this.peers.has(from) && this.pending.has(`rpc:${messageID}`)) {
530
+ this.player = from;
531
+ this.settle(`rpc:${messageID}`, message);
532
+ }
533
+ })
534
+ .catch(() => undefined);
535
+ }
536
+ /** control keeps connection presence separate from command completion. */
537
+ control(event) {
538
+ if (event.type === 'hello') {
539
+ this.onHello(event);
540
+ }
541
+ else if (event.type === 'peer_connected' && validId(event.connectionId)) {
542
+ this.peers.add(event.connectionId);
543
+ }
544
+ else if (event.type === 'peer_disconnected' && typeof event.connectionId === 'string') {
545
+ this.onPeerGone(event.connectionId);
546
+ }
547
+ else if (event.type === 'ack' || event.type === 'error') {
548
+ this.onReceipt(event);
549
+ }
550
+ }
551
+ /** onHello records the peers present at connect and releases waiting requests. */
552
+ onHello(event) {
553
+ if (!validId(event.connectionId) || !Array.isArray(event.peers)) {
554
+ throw new Error('Invalid relay hello');
555
+ }
556
+ // Every live peer is kept: the relay caps a channel at 36 connections,
557
+ // and a client-side cap would let strangers crowd the FF1 out.
558
+ for (const peer of event.peers) {
559
+ if (validId(peer)) {
560
+ this.peers.add(peer);
561
+ }
562
+ }
563
+ this.connected = true;
564
+ this.pinger ??= startPinging(() => this.socket);
565
+ this.readiness.resolve(undefined);
566
+ }
567
+ /** onPeerGone forgets a peer; losing the pinned player ends the connection. */
568
+ onPeerGone(peer) {
569
+ this.peers.delete(peer);
570
+ this.assemblies.delete(peer);
571
+ if (peer === this.player) {
572
+ this.close(new Error('Player disconnected'));
573
+ return;
574
+ }
575
+ // A peer that left before answering may still have received the frame.
576
+ for (const receipt of [...this.receipts.values()]) {
577
+ if (receipt.awaiting.delete(peer)) {
578
+ receipt.result.unanswered.add(peer);
579
+ if (!receipt.awaiting.size) {
580
+ receipt.finish();
581
+ }
582
+ }
583
+ }
584
+ }
585
+ /** assemble enforces one ordered 4 MiB transfer per peer and a shared byte budget. */
586
+ assemble(from, payload) {
587
+ const chunk = JSON.parse(decoder.decode(payload)).relayChunk;
588
+ if (!chunk) {
589
+ return payload;
590
+ }
591
+ const bytes = chunkBytes(chunk);
592
+ this.sweepAssemblies();
593
+ if (chunk.index === 0) {
594
+ this.assemblies.set(from, {
595
+ id: chunk.id,
596
+ count: chunk.count,
597
+ next: 0,
598
+ bytes: 0,
599
+ parts: [],
600
+ expires: Date.now() + ASSEMBLY_TIMEOUT_MS,
601
+ });
602
+ }
603
+ const assembly = this.assemblies.get(from);
604
+ if (!assembly ||
605
+ assembly.id !== chunk.id ||
606
+ assembly.count !== chunk.count ||
607
+ assembly.next !== chunk.index ||
608
+ assembly.bytes + bytes.length > MAX_MESSAGE ||
609
+ this.assemblyBytes() + bytes.length > MAX_ASSEMBLY_BYTES) {
610
+ this.assemblies.delete(from);
611
+ throw new Error('Invalid chunk sequence');
612
+ }
613
+ assembly.parts.push(bytes);
614
+ assembly.bytes += bytes.length;
615
+ assembly.next++;
616
+ if (assembly.next !== assembly.count) {
617
+ return undefined;
618
+ }
619
+ this.assemblies.delete(from);
620
+ return concat(assembly.parts, assembly.bytes);
621
+ }
622
+ /** sweepAssemblies drops transfers past their 180 s reassembly window. */
623
+ sweepAssemblies() {
624
+ const now = Date.now();
625
+ for (const [peer, assembly] of this.assemblies) {
626
+ if (now >= assembly.expires) {
627
+ this.assemblies.delete(peer);
628
+ }
629
+ }
630
+ }
631
+ /** assemblyBytes is the total held by unfinished transfers. */
632
+ assemblyBytes() {
633
+ let total = 0;
634
+ for (const assembly of this.assemblies.values()) {
635
+ total += assembly.bytes;
636
+ }
637
+ return total;
638
+ }
639
+ }
640
+ exports.RelayClient = RelayClient;
641
+ /** backoffMs is a jittered exponential delay for transient relay refusals. */
642
+ function backoffMs(attempt) {
643
+ const ceiling = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** attempt);
644
+ return Math.ceil(ceiling * (0.5 + Math.random() / 2));
645
+ }
646
+ /** refusalState maps a relay refusal code to what it proves about the peer. */
647
+ function refusalState(code) {
648
+ // backpressure and duplicate_packet mean "not forwarded this time", which
649
+ // §10 makes transient; peer_unavailable means the target is gone.
650
+ return code === 'backpressure' || code === 'duplicate_packet' ? 'retry' : 'gone';
651
+ }
652
+ /**
653
+ * mergeChunkResult folds one chunk's receipts into the per-peer states of a
654
+ * body. A peer is `full` only when it acknowledged every chunk; a peer that
655
+ * acknowledged some chunks, or never answered, is `touched`.
656
+ */
657
+ function mergeChunkResult(states, receipt, first) {
658
+ for (const peer of receipt.unanswered) {
659
+ states.set(peer, 'touched');
660
+ }
661
+ for (const peer of receipt.acked) {
662
+ if (first) {
663
+ states.set(peer, 'full');
664
+ }
665
+ else if (states.get(peer) !== 'full') {
666
+ states.set(peer, 'touched');
667
+ }
668
+ }
669
+ for (const [peer, code] of receipt.refused) {
670
+ const previous = states.get(peer);
671
+ if (first || previous === 'retry' || previous === 'gone') {
672
+ states.set(peer, refusalState(code));
673
+ }
674
+ else {
675
+ states.set(peer, 'touched');
676
+ }
677
+ }
678
+ }
679
+ /** refusedLocally is the receipt of a frame this client never wrote. */
680
+ function refusedLocally(to) {
681
+ return {
682
+ empty: false,
683
+ acked: new Set(),
684
+ refused: new Map(to === 'peers' ? [] : [[to, 'peer_unavailable']]),
685
+ unanswered: new Set(),
686
+ };
687
+ }
688
+ /**
689
+ * startPinging sends the §10 liveness `ping` every PING_INTERVAL_MS on
690
+ * whichever socket is current. The timer never keeps the process alive.
691
+ */
692
+ function startPinging(socket) {
693
+ const timer = setInterval(() => {
694
+ try {
695
+ socket().send('ping');
696
+ }
697
+ catch {
698
+ /* The close handler reports a dead socket. */
699
+ }
700
+ }, exports.PING_INTERVAL_MS);
701
+ timer.unref?.();
702
+ return timer;
703
+ }
704
+ /** touched reports whether any frame of a request may have reached a peer. */
705
+ function touched(delivery) {
706
+ return delivery.reached.size > 0 || delivery.written > delivery.refused;
707
+ }
708
+ /** classify types a request failure by whether any part of it may have arrived. */
709
+ function classify(error, delivery) {
710
+ if (error instanceof RelayUndeliveredError || error instanceof RelayAmbiguousError) {
711
+ return error;
712
+ }
713
+ return touched(delivery)
714
+ ? new RelayAmbiguousError(error.message)
715
+ : new RelayUndeliveredError(error.message);
716
+ }
717
+ /** validId reports a relay connection ID. */
718
+ function validId(value) {
719
+ return typeof value === 'string' && ID_PATTERN.test(value);
720
+ }
721
+ /** chunkPayload is chunk `index` of `body`, wrapped in relayChunk when split. */
722
+ function chunkPayload(body, transfer, index, count) {
723
+ const part = body.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE);
724
+ if (count === 1) {
725
+ return part;
726
+ }
727
+ return encoder.encode(JSON.stringify({
728
+ relayChunk: { id: transfer, index, count, data: Buffer.from(part).toString('base64') },
729
+ }));
730
+ }
731
+ /** chunkBytes validates one inbound relayChunk and decodes its data. */
732
+ function chunkBytes(chunk) {
733
+ const { id, index, count, data } = chunk;
734
+ if (typeof id !== 'string' ||
735
+ !ID_PATTERN.test(id) ||
736
+ typeof count !== 'number' ||
737
+ !Number.isInteger(count) ||
738
+ count < 2 ||
739
+ count > 128 ||
740
+ typeof index !== 'number' ||
741
+ !Number.isInteger(index) ||
742
+ index < 0 ||
743
+ index >= count ||
744
+ typeof data !== 'string' ||
745
+ data.length > 43692) {
746
+ throw new Error('Invalid relay chunk');
747
+ }
748
+ const bytes = Uint8Array.from(atob(data), (char) => char.charCodeAt(0));
749
+ if (!bytes.length || bytes.length > CHUNK_SIZE) {
750
+ throw new Error('Invalid chunk size');
751
+ }
752
+ return bytes;
753
+ }
754
+ /** concat joins reassembled parts. */
755
+ function concat(parts, size) {
756
+ const result = new Uint8Array(size);
757
+ let offset = 0;
758
+ for (const part of parts) {
759
+ result.set(part, offset);
760
+ offset += part.length;
761
+ }
762
+ return result;
763
+ }
764
+ /** parseMessage decodes a complete application message, or undefined when incomplete. */
765
+ function parseMessage(body) {
766
+ if (!body) {
767
+ return undefined;
768
+ }
769
+ const parsed = JSON.parse(decoder.decode(body));
770
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
771
+ return undefined;
772
+ }
773
+ return parsed;
774
+ }