@torrent-tv/proxy 2.95.41 → 2.96.1

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.
package/bin/cli.js CHANGED
@@ -31,6 +31,7 @@ import { createUsrsctpStateReader } from "../services/transport/usrsctp-state.js
31
31
  import { startMemoryReport } from "../services/storage/memory-report.js";
32
32
  import { fragmentBufferCollection } from "../services/torrent/worker/client.js";
33
33
  import { collectHealthMetrics } from "../services/transport/health-collector.js";
34
+ import { LOAD_REFRESH_MS, ProxyStateReporter } from "../services/transport/proxy-state.js";
34
35
  import { createPortMapper } from "../services/transport/port-mapper.js";
35
36
  import { installationId } from "../services/identity/installation-id.js";
36
37
  import { classifyNat } from "../services/transport/nat-classifier.js";
@@ -356,6 +357,24 @@ const diagnostics = new Diagnostics({
356
357
  ]
357
358
  });
358
359
 
360
+ /**
361
+ * What this proxy says about itself to the server, which chooses proxies by
362
+ * it: the machine's load and free memory, its room for one more encode, and
363
+ * the films it holds. Filled in once the server parts exist; before that it
364
+ * says only the machine.
365
+ *
366
+ * @type {() => Promise<{ metrics: object, holds: object[] }>}
367
+ */
368
+ let describeSelf = async () => ({ metrics: {}, holds: [] });
369
+
370
+ // The state goes to the server when it changes rather than when it is asked
371
+ // for (torrent-tv/meta#36). The load average has no event, so it is looked at
372
+ // on the cadence the kernel recomputes it.
373
+ const stateReporter = new ProxyStateReporter({
374
+ describe: () => describeSelf(),
375
+ send: (state) => tunnelClient?.sendState(state)
376
+ });
377
+
359
378
  try {
360
379
  logToFile(options.logFile);
361
380
  if (transcodeAudio) {
@@ -379,10 +398,40 @@ try {
379
398
  onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event),
380
399
  // Late-bound for the same reason: what the connections carry beyond the
381
400
  // film is counted by the data-channel handler, built further down.
382
- serviceShare: () => dataChannelHandler?.serviceShare?.() ?? null
401
+ serviceShare: () => dataChannelHandler?.serviceShare?.() ?? null,
402
+ onStateChanged: () => void stateReporter.changed()
383
403
  });
384
404
  app = started.app;
385
405
  actualPort = started.port;
406
+ describeSelf = async () => {
407
+ // Which films this proxy holds: a viewer sent to a proxy that is
408
+ // downloading their film costs it the encode and nothing else, while the
409
+ // same viewer sent anywhere else starts the download from nothing. Asked
410
+ // of the worker, so a film this proxy let go of is not claimed.
411
+ let holds = [];
412
+ try {
413
+ holds = (await started.torrentPool?.heldTorrents?.()) ?? [];
414
+ } catch {
415
+ // silent-ok: a proxy that cannot say what it holds is scored on its
416
+ // machine alone, which is what every proxy was scored on until now.
417
+ }
418
+ // WHETHER THIS MACHINE HAS ROOM FOR ONE MORE ENCODE (roadmap item 97,
419
+ // step 14): what holds a place on it now, and the speed that leaves every
420
+ // output at. The load average cannot say this — an encoder admitted a
421
+ // moment ago has not raised it yet.
422
+ const encode = started.outputParts?.admission?.headroom?.() ?? null;
423
+ // The uptime is left out: it changes every second, nothing chooses by it,
424
+ // and it would make every description differ from the last.
425
+ const { uptime: _uptime, ...machine } = collectHealthMetrics({ availableMemory });
426
+ return {
427
+ metrics: {
428
+ ...machine,
429
+ ...(encode ? { encodeSpeedX: encode.encodeSpeedX, encodeOccupiedCostSec: encode.occupiedCostSec } : {})
430
+ },
431
+ holds
432
+ };
433
+ };
434
+ setInterval(() => void stateReporter.changed(), LOAD_REFRESH_MS).unref();
386
435
  const directBaseUrl = explicitBaseUrl || `http://${bindHost}:${actualPort}`;
387
436
 
388
437
  // A native fault writes the whole address space out — 4.18 GB each on the
@@ -568,33 +617,6 @@ try {
568
617
  onSignal(sessionId, signal) {
569
618
  webRtcManager?.handleSignal(sessionId, signal);
570
619
  },
571
- async onHealthRequest() {
572
- // Which films this proxy holds travels with the health poll the browser
573
- // already makes before it picks one: a viewer sent to a proxy that is
574
- // downloading their film costs it the encode and nothing else, while the
575
- // same viewer sent anywhere else starts the download from nothing. Asked
576
- // of the worker, so a film this proxy let go of is not claimed.
577
- let holds = [];
578
- try {
579
- holds = (await started?.torrentPool?.heldTorrents?.()) ?? [];
580
- } catch {
581
- // silent-ok: a proxy that cannot say what it holds is scored on its
582
- // machine alone, which is what every proxy was scored on until now.
583
- }
584
- // WHETHER THIS MACHINE HAS ROOM FOR ONE MORE ENCODE, read by the pool
585
- // before any film is chosen (roadmap item 97, step 14): what holds a
586
- // place on it now, and the speed that leaves every output at. The load
587
- // average cannot say this — an encoder admitted a moment ago has not
588
- // raised it yet.
589
- const encode = started?.outputParts?.admission?.headroom?.() ?? null;
590
- return {
591
- metrics: {
592
- ...collectHealthMetrics({ availableMemory }),
593
- ...(encode ? { encodeSpeedX: encode.encodeSpeedX, encodeOccupiedCostSec: encode.occupiedCostSec } : {})
594
- },
595
- holds
596
- };
597
- },
598
620
  // Whether this host could sustain a file it has only been told about. The
599
621
  // same arithmetic the first offer uses, against this host's own startup
600
622
  // benchmarks — no torrent, no bytes, no ffmpeg — so the browser can ask
@@ -604,6 +626,9 @@ try {
604
626
  return started?.outputParts?.quality?.predictOfferedHeights?.(mediaInfo) ?? null;
605
627
  },
606
628
  onConnect() {
629
+ // A new connection — a reconnect, or a move to another server instance —
630
+ // has not heard this proxy's state: send it whether or not it changed.
631
+ void stateReporter.resend();
607
632
  // Re-register on every tunnel connect/reconnect so the server's
608
633
  // in-memory store stays consistent after server restarts.
609
634
  void registerClientSafe().catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.95.41",
3
+ "version": "2.96.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -123,7 +123,11 @@ export async function startProxyServer({
123
123
  // Where the proxy writes its own log. Its DIRECTORY is what matters here:
124
124
  // the browser's half of every session is written beside it, so the two are
125
125
  // on one durable disk and join by name.
126
- logFile = ""
126
+ logFile = "",
127
+ // Told whenever what the server chooses proxies by may have changed here:
128
+ // the films held, an encoder started or ended, a viewer came or went
129
+ // (`transport/proxy-state.js`).
130
+ onStateChanged = () => undefined
127
131
  }) {
128
132
  const app = Fastify({
129
133
  // No practical body-size limit — the proxy server is localhost-only and
@@ -159,6 +163,7 @@ export async function startProxyServer({
159
163
  const torrentPool = new WorkerTorrentPool({
160
164
  memoryBytes,
161
165
  stateDir,
166
+ onHoldingsChanged: () => onStateChanged(),
162
167
  onSourceForgotten: ({ sourceKey }) => {
163
168
  sourcePreparation?.forget(sourceKey);
164
169
  mediaReads.forget(sourceKey);
@@ -613,7 +618,8 @@ export async function startProxyServer({
613
618
  indexMemory,
614
619
  serviceShare,
615
620
  readMetadataActivity: () => containerOrchestrator.activity(),
616
- onViewerChanged: () => { void sourcePreparation?.refresh(); },
621
+ onViewerChanged: () => { void sourcePreparation?.refresh(); onStateChanged(); },
622
+ onEncodersChanged: () => onStateChanged(),
617
623
  sourceInputsFor: (output, index) => downloadMaps.inputsForOutput(output.file.sourceKey, output.outputKey, index),
618
624
  readSourceMedia: async (params) => {
619
625
  await playbackPlanner.getPlan(params);
@@ -1035,6 +1035,7 @@ export class EncodeRuns {
1035
1035
  admittedInput.release();
1036
1036
  }
1037
1037
  this.noteRunEnded(session, run, ended);
1038
+ this.#host.encodersChanged?.();
1038
1039
  }
1039
1040
  });
1040
1041
  if (admittedInput.original) {
@@ -1070,6 +1071,7 @@ export class EncodeRuns {
1070
1071
  });
1071
1072
  }
1072
1073
  this.#runsByInputToken.set(inputToken, run);
1074
+ this.#host.encodersChanged?.();
1073
1075
  // THE ONE FAULT THAT IS OTHERWISE SILENT, asked before this run produces a
1074
1076
  // frame. It lost its caller in a refactor on 2026-09-04 and had none until
1075
1077
  // 2026-09-15 — not by a decision, which is why it is back rather than gone.
@@ -226,7 +226,10 @@ export function wireOutputs({
226
226
  // the transport measured it (`transport/delivery-shares.js`); null while
227
227
  // nothing has been delivered.
228
228
  serviceShare = () => null,
229
- onViewerChanged = null}) {
229
+ onViewerChanged = null,
230
+ // Told when an encoder starts or ends: the room this host has for one more
231
+ // encode changes with it, and the server chooses proxies by that room.
232
+ onEncodersChanged = null}) {
230
233
  const parts = {};
231
234
  const audioDescriptionOf = (output) => (getCachedAudioTracks?.({
232
235
  sourceKey: output.file.sourceKey, fileIndex: output.spec.grid?.fileIndex ?? output.file.fileIndex
@@ -339,6 +342,7 @@ export function wireOutputs({
339
342
  get encoders() { return parts.encoders; },
340
343
  invalidateWaits: (output) => parts.serving.invalidateWaits(output),
341
344
  productionFailed: (output) => parts.renditions.noteProductionFailed(output),
345
+ encodersChanged: () => onEncodersChanged?.(),
342
346
  });
343
347
  // How much film is ready in front of the viewers, said; and the spare soundtracks fetched once it is full.
344
348
  parts.cushion = new CushionReport({
@@ -103,6 +103,25 @@ export class CompletedFiles {
103
103
  return total;
104
104
  }
105
105
 
106
+ /**
107
+ * Every file held whole, by torrent: what this proxy can serve without any
108
+ * swarm. Read without counting as a read, so asking does not keep a file.
109
+ *
110
+ * @returns {Map<string, { fileIndexes: number[], bytes: number }>} Infohash → its files.
111
+ */
112
+ heldByTorrent() {
113
+ const byTorrent = new Map();
114
+ for (const [key, file] of this.#held) {
115
+ const slash = key.lastIndexOf("/");
116
+ const infoHash = key.slice(0, slash);
117
+ const entry = byTorrent.get(infoHash) ?? { fileIndexes: [], bytes: 0 };
118
+ entry.fileIndexes.push(Number(key.slice(slash + 1)));
119
+ entry.bytes += file.length;
120
+ byTorrent.set(infoHash, entry);
121
+ }
122
+ return byTorrent;
123
+ }
124
+
106
125
  /**
107
126
  * @param {string} infoHash
108
127
  * @param {number} fileIndex
@@ -111,11 +111,13 @@ export class TorrentWorkerClient {
111
111
  /** @type {(event: { sourceKey: string, fileIndexes: number[] }) => void} */
112
112
  #onPiecesArrived;
113
113
  #onSourceForgotten;
114
+ /** Told when what this proxy holds changed; see `Event.HOLDINGS_CHANGED`. */
115
+ #onHoldingsChanged;
114
116
 
115
117
  /**
116
118
  * @param {{ memoryBytes?: number, stateDir?: string, onPiecesArrived?: (event: object) => void }} [options]
117
119
  */
118
- constructor({ memoryBytes, stateDir, onPiecesArrived, onSourceForgotten } = {}) {
120
+ constructor({ memoryBytes, stateDir, onPiecesArrived, onSourceForgotten, onHoldingsChanged } = {}) {
119
121
  this.#worker = new Worker(fileURLToPath(WORKER_URL), {
120
122
  // `stateDir` travels because the worker writes heap snapshots of its own
121
123
  // isolate there. It cannot choose a directory any other way: a worker may
@@ -126,6 +128,7 @@ export class TorrentWorkerClient {
126
128
  this.#caller = createCaller(this.#worker);
127
129
  this.#onPiecesArrived = onPiecesArrived ?? (() => undefined);
128
130
  this.#onSourceForgotten = onSourceForgotten ?? (() => undefined);
131
+ this.#onHoldingsChanged = onHoldingsChanged ?? (() => undefined);
129
132
 
130
133
  this.#worker.on("message", (message) => {
131
134
  // A failed read must fail its stream. This is checked BEFORE the caller
@@ -241,6 +244,9 @@ export class TorrentWorkerClient {
241
244
  case Event.SOURCE_FORGOTTEN:
242
245
  this.#onSourceForgotten({ sourceKey: message.sourceKey });
243
246
  break;
247
+ case Event.HOLDINGS_CHANGED:
248
+ this.#onHoldingsChanged();
249
+ break;
244
250
  case Event.PIECES_ARRIVED:
245
251
  case Event.PIECES_CHANGED:
246
252
  // ANNOUNCED by the thread that owns the swarm, acted on here: the
@@ -155,7 +155,13 @@ export const Event = {
155
155
  * for anything: an ordinary read of an ordinary file, with no piece store
156
156
  * between them and nothing that can refuse it for want of memory.
157
157
  */
158
- FILE_COMPLETE: "file-complete"
158
+ FILE_COMPLETE: "file-complete",
159
+ /**
160
+ * What this proxy holds has changed: a torrent was added or closed, a file
161
+ * was kept whole or let go. Announced by this thread, which owns both; the
162
+ * main thread tells the server, which chooses proxies by what they hold.
163
+ */
164
+ HOLDINGS_CHANGED: "holdings-changed"
159
165
  };
160
166
 
161
167
  /**
@@ -381,6 +381,7 @@ async function runCommand(command, params, id) {
381
381
  // stand-ins, which is only cleared on shutdown and would name films this
382
382
  // proxy let go of hours ago.
383
383
  const held = [];
384
+ const whole = completedFiles.heldByTorrent();
384
385
  for (const torrent of pool.client?.torrents ?? []) {
385
386
  const infoHash = String(torrent?.infoHash ?? "");
386
387
  if (!infoHash) {
@@ -392,8 +393,15 @@ async function runCommand(command, params, id) {
392
393
  // nothing, so the share is reported and the decision is made where
393
394
  // the viewer is.
394
395
  progress: Number.isFinite(torrent?.progress) ? torrent.progress : 0,
395
- bytes: Number.isFinite(torrent?.downloaded) ? torrent.downloaded : 0
396
+ bytes: Number.isFinite(torrent?.downloaded) ? torrent.downloaded : 0,
397
+ wholeFiles: whole.get(infoHash)?.fileIndexes ?? []
396
398
  });
399
+ whole.delete(infoHash);
400
+ }
401
+ // A film downloaded whole is kept as files after its torrent is removed:
402
+ // this proxy still holds it, and serves it with no swarm at all.
403
+ for (const [infoHash, files] of whole) {
404
+ held.push({ infoHash, progress: null, bytes: files.bytes, wholeFiles: files.fileIndexes });
397
405
  }
398
406
  return { held };
399
407
  }
@@ -525,6 +533,7 @@ async function runCommand(command, params, id) {
525
533
  // other. What arrives is their share; what goes back is what they hold
526
534
  // after it, which is what the owner divides by next time.
527
535
  const after = await completedFiles.allow(Number(params.bytes));
536
+ if (after.removed > 0) holdingsChanged();
528
537
  return after.bytes;
529
538
  }
530
539
 
@@ -848,6 +857,7 @@ function ensureArrivalsWired(sourceKey, torrent) {
848
857
  return;
849
858
  }
850
859
  arrivalsWired.add(torrent);
860
+ holdingsChanged();
851
861
  torrent.on("verified", () => announceArrivals(sourceKey, torrent));
852
862
  torrent.on("piece-withdrawn", () => announceArrivals(sourceKey, torrent, "withdrawn"));
853
863
  torrent.once("close", () => {
@@ -858,9 +868,15 @@ function ensureArrivalsWired(sourceKey, torrent) {
858
868
  const retained = wholeSources.describe(sourceKey);
859
869
  announceWholeFiles(sourceKey, retained);
860
870
  } else parentPort.postMessage({ type: Event.SOURCE_FORGOTTEN, sourceKey });
871
+ holdingsChanged();
861
872
  });
862
873
  }
863
874
 
875
+ /** Say that what this proxy holds has changed (`Event.HOLDINGS_CHANGED`). */
876
+ function holdingsChanged() {
877
+ parentPort.postMessage({ type: Event.HOLDINGS_CHANGED });
878
+ }
879
+
864
880
  /**
865
881
  * Files this proxy has downloaded whole. One directory, two readers of it: this
866
882
  * thread writes them, the main thread serves them without asking anybody.
@@ -913,6 +929,7 @@ pool.buildStoresWith({
913
929
  });
914
930
  void completedFiles.adopt(() => null).then((adopted) => {
915
931
  if (adopted > 0) {
932
+ holdingsChanged();
916
933
  logger.info(
917
934
  `whole files: took up ${adopted} file(s) a previous life left in ${completedFiles.root}`
918
935
  );
@@ -1008,6 +1025,7 @@ async function keepWholeFiles() {
1008
1025
  length: kept.length,
1009
1026
  name: kept.name
1010
1027
  });
1028
+ holdingsChanged();
1011
1029
  }
1012
1030
  } catch (error) {
1013
1031
  logger.warn(`whole files: could not keep "${file?.name}": ${error?.message ?? error}`);
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @file What this proxy tells the server about itself, sent when it changes.
3
+ *
4
+ * The server chooses a proxy for every viewer from a table of proxies it keeps
5
+ * (torrent-tv/meta#36): their load, their room for one more encode, and the
6
+ * films they hold. It used to ask every proxy for all of it on every choice and
7
+ * wait up to two seconds for the answers; now each proxy says it when it
8
+ * changes, and the server answers a choice from what it already knows.
9
+ *
10
+ * `changed()` is the one entry: called on every event that may change the
11
+ * state (a torrent added or closed, a file kept whole, an encoder started or
12
+ * ended, a viewer coming or going) and on the cadence of the load average,
13
+ * which has no event of its own. Calls that arrive while one description is
14
+ * being made are joined into one more after it, and a description identical
15
+ * to the last one sent is not sent again.
16
+ */
17
+
18
+ /**
19
+ * How often the load average is looked at. Linux recomputes it every five
20
+ * seconds (`LOAD_FREQ` in `kernel/sched/loadavg.c`); reading it more often
21
+ * finds the same number, less often lets the server's copy lag behind it.
22
+ */
23
+ export const LOAD_REFRESH_MS = 5_000;
24
+
25
+ export class ProxyStateReporter {
26
+ /** @type {() => Promise<object> | object} */
27
+ #describe;
28
+
29
+ /** @type {(state: object) => void} */
30
+ #send;
31
+
32
+ /** @type {Promise<void> | null} */
33
+ #running = null;
34
+
35
+ #again = false;
36
+
37
+ /** What was sent last, as text, so an unchanged state is not sent again. */
38
+ #lastSent = "";
39
+
40
+ /**
41
+ * @param {object} params
42
+ * @param {() => Promise<object> | object} params.describe - This proxy's state now.
43
+ * @param {(state: object) => void} params.send - Deliver it to the server.
44
+ */
45
+ constructor({ describe, send }) {
46
+ this.#describe = describe;
47
+ this.#send = send;
48
+ }
49
+
50
+ /**
51
+ * Something that may change the state happened: describe it and send what
52
+ * differs. Never throws; never waits for the caller.
53
+ *
54
+ * @returns {Promise<void>}
55
+ */
56
+ changed() {
57
+ if (this.#running) {
58
+ this.#again = true;
59
+ return this.#running;
60
+ }
61
+ this.#running = this.#run().finally(() => {
62
+ this.#running = null;
63
+ });
64
+ return this.#running;
65
+ }
66
+
67
+ /**
68
+ * Send the state even when it is unchanged: a new connection to the server
69
+ * (a reconnect, or a move to another server instance) has not heard it.
70
+ *
71
+ * @returns {Promise<void>}
72
+ */
73
+ resend() {
74
+ this.#lastSent = "";
75
+ return this.changed();
76
+ }
77
+
78
+ async #run() {
79
+ do {
80
+ this.#again = false;
81
+ let state;
82
+ try {
83
+ state = await this.#describe();
84
+ } catch {
85
+ // silent-ok: a state that cannot be described now is described at the
86
+ // next change or load reading; the server keeps the last one it heard.
87
+ continue;
88
+ }
89
+ const text = JSON.stringify(state);
90
+ if (text === this.#lastSent) continue;
91
+ this.#lastSent = text;
92
+ this.#send(state);
93
+ } while (this.#again);
94
+ }
95
+ }
@@ -20,7 +20,6 @@
20
20
 
21
21
  import { WebSocket } from "ws";
22
22
 
23
- /** @import { HealthMetrics } from './health-collector.js' */
24
23
 
25
24
  /**
26
25
  * Configuration for the tunnel client.
@@ -52,9 +51,6 @@ import { WebSocket } from "ws";
52
51
  * Answered from this host's startup benchmarks alone: no torrent is added, no
53
52
  * bytes are fetched and ffmpeg is not run, so it costs milliseconds and can
54
53
  * be asked of every proxy in the pool at once.
55
- * @property {() => HealthMetrics} [onHealthRequest]
56
- * Called when the server sends a `health-request` message. The return value is
57
- * sent back as `health-response` and used by the server to score this proxy.
58
54
  * @property {(message: string) => void} [onLog]
59
55
  * Optional structured log sink.
60
56
  */
@@ -136,7 +132,6 @@ export function createTunnelClient({
136
132
  baseUrl = "",
137
133
  onSignal,
138
134
  onConnect,
139
- onHealthRequest,
140
135
  onCanServeRequest,
141
136
  onLog,
142
137
  connectionLifetimeMs = CONNECTION_LIFETIME_MS
@@ -278,6 +273,14 @@ export function createTunnelClient({
278
273
  return;
279
274
  }
280
275
 
276
+ // The server's answer to this proxy's keepalive, echoed at once on the
277
+ // same connection: the server measures the tunnel round trip from it on
278
+ // its own clock, and chooses proxies partly by that.
279
+ if (message.type === "rtt-probe") {
280
+ send({ type: "rtt-echo", sentAt: message.sentAt }, connection);
281
+ return;
282
+ }
283
+
281
284
  // Could this host serve a file it is only told ABOUT? Asked when the
282
285
  // proxy a viewer landed on has refused the file, so the browser can be
283
286
  // sent somewhere that will work instead of being shown an error. The
@@ -297,29 +300,6 @@ export function createTunnelClient({
297
300
  send({ type: "can-serve-response", requestId: message.requestId, offer }, connection);
298
301
  return;
299
302
  }
300
-
301
- // Health check: server requests current metrics for proxy scoring.
302
- if (message.type === "health-request") {
303
- // Awaited: the answer now carries which films this proxy holds, and the
304
- // truthful list of those lives on the torrent thread.
305
- void (async () => {
306
- let answer = {};
307
- try {
308
- answer = typeof onHealthRequest === "function" ? await onHealthRequest() : {};
309
- } catch {
310
- // silent-ok: a proxy that cannot describe itself is scored on
311
- // nothing rather than not answered at all, which would drop it out
312
- // of every selection until the next poll.
313
- }
314
- send({
315
- type: "health-response",
316
- requestId: message.requestId,
317
- metrics: answer?.metrics ?? answer ?? {},
318
- holds: Array.isArray(answer?.holds) ? answer.holds : []
319
- }, connection);
320
- })();
321
- return;
322
- }
323
303
  });
324
304
 
325
305
  connection.addEventListener("close", (event) => {
@@ -508,6 +488,21 @@ export function createTunnelClient({
508
488
  }
509
489
  },
510
490
 
491
+ /**
492
+ * Tell the server this proxy's state: its load, its room for one more
493
+ * encode and the films it holds (`transport/proxy-state.js`). Over every
494
+ * open connection: during a move between server instances both keep a
495
+ * table of proxies, and each chooses from its own.
496
+ *
497
+ * @param {{ metrics?: object, holds?: object[] }} state
498
+ * @returns {void}
499
+ */
500
+ sendState(state) {
501
+ for (const connection of openConnections) {
502
+ send({ type: "proxy-state", metrics: state?.metrics ?? {}, holds: Array.isArray(state?.holds) ? state.holds : [] }, connection);
503
+ }
504
+ },
505
+
511
506
  /**
512
507
  * Forward a WebRTC signal (SDP answer or ICE candidate) from this proxy
513
508
  * to the browser via the server tunnel.