@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 +53 -28
- package/package.json +1 -1
- package/server.js +8 -2
- package/services/encode/EncodeRuns.js +2 -0
- package/services/server/wire-outputs.js +5 -1
- package/services/storage/files/CompletedFiles.js +19 -0
- package/services/torrent/worker/client.js +7 -1
- package/services/torrent/worker/protocol.js +7 -1
- package/services/torrent/worker/worker.js +19 -1
- package/services/transport/proxy-state.js +95 -0
- package/services/transport/tunnel-client.js +23 -28
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
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.
|