@decentnetwork/lan 0.1.193 → 0.1.195
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/tun-helper-darwin-amd64 +0 -0
- package/bin/tun-helper-darwin-arm64 +0 -0
- package/bin/tun-helper-linux-amd64 +0 -0
- package/bin/tun-helper-linux-arm64 +0 -0
- package/dist/cli/commands.js +46 -3
- package/dist/proxy/hls-prefetch.d.ts +21 -5
- package/dist/proxy/hls-prefetch.js +166 -24
- package/dist/proxy/multi-exit-router.js +102 -29
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/cli/commands.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* CLI command handlers
|
|
3
3
|
*/
|
|
4
4
|
import { resolve, dirname, join } from "path";
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync } from "fs";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, copyFileSync } from "fs";
|
|
6
6
|
import { createConnection } from "net";
|
|
7
7
|
import { createRequire } from "module";
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
@@ -1421,6 +1421,37 @@ export async function cmdProxyTrustCa(args) {
|
|
|
1421
1421
|
return;
|
|
1422
1422
|
}
|
|
1423
1423
|
if (process.platform === "linux") {
|
|
1424
|
+
// WSL: the browser runs on the WINDOWS side and trusts the WINDOWS cert
|
|
1425
|
+
// store — installing into this distro's Linux store does nothing for it.
|
|
1426
|
+
// certutil.exe is callable from WSL and the per-user ROOT store needs no
|
|
1427
|
+
// admin (Windows pops a confirmation dialog the user must accept).
|
|
1428
|
+
const isWsl = existsSync("/proc/version") &&
|
|
1429
|
+
readFileSync("/proc/version", "utf-8").toLowerCase().includes("microsoft");
|
|
1430
|
+
if (isWsl) {
|
|
1431
|
+
const winCopy = "/mnt/c/Users/Public/agentnet-mitm-ca.pem";
|
|
1432
|
+
const winPath = "C:\\Users\\Public\\agentnet-mitm-ca.pem";
|
|
1433
|
+
console.log("WSL detected — the browser lives on Windows, installing into the Windows user trust store.");
|
|
1434
|
+
try {
|
|
1435
|
+
copyFileSync(caCertPath, winCopy);
|
|
1436
|
+
console.log("A Windows security dialog will pop up — click Yes to trust the CA...");
|
|
1437
|
+
const w = spawnSync("certutil.exe", ["-user", "-addstore", "-f", "ROOT", winPath], { stdio: "inherit" });
|
|
1438
|
+
if (w.status === 0) {
|
|
1439
|
+
console.log("✓ Windows store updated. Chrome/Edge on Windows pick it up immediately (reload the page).");
|
|
1440
|
+
}
|
|
1441
|
+
else {
|
|
1442
|
+
console.log("Install failed or dialog declined — in a Windows terminal run:");
|
|
1443
|
+
console.log(` certutil -user -addstore -f ROOT "${winPath}"`);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
catch (e) {
|
|
1447
|
+
console.log(`Could not copy the cert to the Windows side (${e.message}). Manually:`);
|
|
1448
|
+
console.log(` cp "${caCertPath}" ${winCopy}`);
|
|
1449
|
+
console.log(` certutil.exe -user -addstore -f ROOT "${winPath}"`);
|
|
1450
|
+
}
|
|
1451
|
+
console.log("(Linux-side clients like curl inside WSL would additionally need the system CA store:");
|
|
1452
|
+
console.log(` sudo cp "${caCertPath}" /usr/local/share/ca-certificates/agentnet-mitm-ca.crt && sudo update-ca-certificates)`);
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1424
1455
|
console.log("Installing into the system CA store (asks for your sudo password)...");
|
|
1425
1456
|
const cp = spawnSync("sudo", ["cp", caCertPath, "/usr/local/share/ca-certificates/agentnet-mitm-ca.crt"], { stdio: "inherit" });
|
|
1426
1457
|
const up = cp.status === 0 ? spawnSync("sudo", ["update-ca-certificates"], { stdio: "inherit" }) : cp;
|
|
@@ -1431,8 +1462,20 @@ export async function cmdProxyTrustCa(args) {
|
|
|
1431
1462
|
console.log("(package: libnss3-tools). Firefox: Settings → Certificates → Import.");
|
|
1432
1463
|
return;
|
|
1433
1464
|
}
|
|
1434
|
-
|
|
1435
|
-
|
|
1465
|
+
if (process.platform === "win32") {
|
|
1466
|
+
console.log("Installing into the Windows user trust store (a security dialog will pop up — click Yes)...");
|
|
1467
|
+
const w = spawnSync("certutil", ["-user", "-addstore", "-f", "ROOT", caCertPath], { stdio: "inherit" });
|
|
1468
|
+
if (w.status === 0) {
|
|
1469
|
+
console.log("✓ Trusted. Chrome/Edge pick it up immediately (reload the page).");
|
|
1470
|
+
}
|
|
1471
|
+
else {
|
|
1472
|
+
console.log("Install failed or dialog declined — run manually (admin terminal for the machine store):");
|
|
1473
|
+
console.log(` certutil -addstore -f ROOT "${caCertPath}"`);
|
|
1474
|
+
}
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
console.log(`Unsupported platform ${process.platform} — import this CA into your browser's trust store manually:`);
|
|
1478
|
+
console.log(` ${caCertPath}`);
|
|
1436
1479
|
}
|
|
1437
1480
|
export async function cmdProxyRouter(args) {
|
|
1438
1481
|
const dir = args.configDir || ConfigLoader.defaultConfigDir();
|
|
@@ -20,6 +20,15 @@ export interface HlsVariant {
|
|
|
20
20
|
* Returns [] for a media playlist (no variants).
|
|
21
21
|
*/
|
|
22
22
|
export declare function parseM3u8Variants(playlist: string, baseUrl: string): HlsVariant[];
|
|
23
|
+
/** Remove master-playlist variants above a delivery-safe bandwidth. Variant
|
|
24
|
+
* metadata and its following URI are removed as one pair. Media playlists are
|
|
25
|
+
* returned unchanged. */
|
|
26
|
+
export declare function capM3u8Variants(playlist: string, maxBandwidth: number): string;
|
|
27
|
+
/** Map variants above the delivery cap to the fastest variant that remains.
|
|
28
|
+
* Some players keep polling a previously selected media-playlist URL even after
|
|
29
|
+
* reloading the capped master playlist. The proxy can use this map to fetch the
|
|
30
|
+
* safe media playlist while preserving the browser-facing request URL. */
|
|
31
|
+
export declare function buildM3u8VariantFallbacks(playlist: string, baseUrl: string, maxBandwidth: number): Map<string, string>;
|
|
23
32
|
/**
|
|
24
33
|
* Extract the media-segment URLs from an m3u8 playlist, resolved against the
|
|
25
34
|
* playlist's own URL. Returns [] for master playlists (variant lists) — those
|
|
@@ -53,15 +62,15 @@ export interface HlsPrefetcherOptions {
|
|
|
53
62
|
* exit pool (default 4). 1 disables striping. Lets ONE segment ride the SUM
|
|
54
63
|
* of the exits' bandwidth instead of a single throttled connection. */
|
|
55
64
|
stripes?: number;
|
|
56
|
-
/** Dynamic stripe count:
|
|
57
|
-
*
|
|
58
|
-
* With one working exit it returns 1 (whole fetch — striping onto a single
|
|
59
|
-
* exit just serializes and stalls); as exits recover it rises and aggregation
|
|
60
|
-
* resumes. Overrides `stripes` when provided. */
|
|
65
|
+
/** Dynamic stripe count: may reflect either distinct exits or parallel
|
|
66
|
+
* connections on one proven exit. Overrides `stripes` when provided. */
|
|
61
67
|
stripesFn?: () => number;
|
|
62
68
|
/** Delay before duplicating a slow whole-segment fetch. Primarily exposed for
|
|
63
69
|
* deterministic tests; default 3.5s is just below CCTV's 4s live segment. */
|
|
64
70
|
wholeHedgeMs?: number;
|
|
71
|
+
/** Delay before duplicating a slow byte range. Exposed for deterministic
|
|
72
|
+
* tests; default 2s leaves time for the rescue to beat a 4s live segment. */
|
|
73
|
+
stripeHedgeMs?: number;
|
|
65
74
|
/** Stripe chunk size — also the first probe chunk (default 256 KB). Segments
|
|
66
75
|
* smaller than this aren't striped. */
|
|
67
76
|
stripeChunkBytes?: number;
|
|
@@ -89,6 +98,13 @@ export declare class HlsPrefetcher {
|
|
|
89
98
|
* lands — lets a player request "join" an in-flight prefetch instead of
|
|
90
99
|
* double-downloading. */
|
|
91
100
|
pending(url: string): Promise<CacheEntry | null> | null;
|
|
101
|
+
/** Delay only the first response for a media-playlist URL while caching up to
|
|
102
|
+
* two of its oldest listed segments. This intentionally starts playback
|
|
103
|
+
* about 4-8 seconds behind the live edge, giving the player enough media
|
|
104
|
+
* buffer to absorb an occasional link-wide five-second slowdown. Later
|
|
105
|
+
* refreshes are never delayed. A fetch that outlives the wait remains tracked
|
|
106
|
+
* so the player's segment request joins it instead of downloading twice. */
|
|
107
|
+
primePlaylist(playlistUrl: string, body: string, maxWaitMs?: number, targetSegments?: number): Promise<number>;
|
|
92
108
|
/** Called with every playlist body that passes through the router: parse it
|
|
93
109
|
* and top up the prefetch pipeline for its upcoming segments. */
|
|
94
110
|
onPlaylist(playlistUrl: string, body: string): void;
|
|
@@ -23,6 +23,9 @@ const SEGMENT_EXTS = [".ts", ".m4s", ".m4a", ".m4v", ".aac", ".mp3", ".mp4", ".v
|
|
|
23
23
|
const STRIPE_DEADLINE_MS = 6000;
|
|
24
24
|
/** How many exits to try for one stripe before giving up on it. */
|
|
25
25
|
const STRIPE_TRIES = 3;
|
|
26
|
+
/** Start a duplicate of the same half-range before the four-second live buffer
|
|
27
|
+
* is exhausted. The first byte-correct response wins and aborts its siblings. */
|
|
28
|
+
const STRIPE_HEDGE_MS = 2000;
|
|
26
29
|
/** Whole-segment hedge delay: a live segment is ~4s of video; one that hasn't
|
|
27
30
|
* landed in this long is late (a healthy exit delivers in 1-3.5s), so a
|
|
28
31
|
* parallel duplicate on another exit is launched. Rarely fires in steady
|
|
@@ -78,6 +81,53 @@ export function parseM3u8Variants(playlist, baseUrl) {
|
|
|
78
81
|
}
|
|
79
82
|
return out;
|
|
80
83
|
}
|
|
84
|
+
/** Remove master-playlist variants above a delivery-safe bandwidth. Variant
|
|
85
|
+
* metadata and its following URI are removed as one pair. Media playlists are
|
|
86
|
+
* returned unchanged. */
|
|
87
|
+
export function capM3u8Variants(playlist, maxBandwidth) {
|
|
88
|
+
if (!playlist.includes("#EXT-X-STREAM-INF") || maxBandwidth <= 0)
|
|
89
|
+
return playlist;
|
|
90
|
+
const out = [];
|
|
91
|
+
let dropUri = false;
|
|
92
|
+
for (const line of playlist.split(/\r?\n/)) {
|
|
93
|
+
if (line.trim().startsWith("#EXT-X-STREAM-INF")) {
|
|
94
|
+
const bw = /BANDWIDTH=(\d+)/.exec(line);
|
|
95
|
+
dropUri = Boolean(bw && Number(bw[1]) > maxBandwidth);
|
|
96
|
+
if (!dropUri)
|
|
97
|
+
out.push(line);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (dropUri) {
|
|
101
|
+
if (line.trim().length === 0 || line.trim().startsWith("#"))
|
|
102
|
+
continue;
|
|
103
|
+
dropUri = false;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
out.push(line);
|
|
107
|
+
}
|
|
108
|
+
return out.join("\n");
|
|
109
|
+
}
|
|
110
|
+
/** Map variants above the delivery cap to the fastest variant that remains.
|
|
111
|
+
* Some players keep polling a previously selected media-playlist URL even after
|
|
112
|
+
* reloading the capped master playlist. The proxy can use this map to fetch the
|
|
113
|
+
* safe media playlist while preserving the browser-facing request URL. */
|
|
114
|
+
export function buildM3u8VariantFallbacks(playlist, baseUrl, maxBandwidth) {
|
|
115
|
+
const out = new Map();
|
|
116
|
+
if (maxBandwidth <= 0)
|
|
117
|
+
return out;
|
|
118
|
+
const variants = parseM3u8Variants(playlist, baseUrl).filter((v) => v.url.length > 0);
|
|
119
|
+
const allowed = variants
|
|
120
|
+
.filter((v) => v.bandwidth <= maxBandwidth)
|
|
121
|
+
.sort((a, b) => b.bandwidth - a.bandwidth);
|
|
122
|
+
const fallback = allowed[0];
|
|
123
|
+
if (!fallback)
|
|
124
|
+
return out;
|
|
125
|
+
for (const variant of variants) {
|
|
126
|
+
if (variant.bandwidth > maxBandwidth)
|
|
127
|
+
out.set(variant.url, fallback.url);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
81
131
|
/**
|
|
82
132
|
* Extract the media-segment URLs from an m3u8 playlist, resolved against the
|
|
83
133
|
* playlist's own URL. Returns [] for master playlists (variant lists) — those
|
|
@@ -104,6 +154,7 @@ export class HlsPrefetcher {
|
|
|
104
154
|
#cache = new Map(); // Map preserves insertion order → LRU-ish
|
|
105
155
|
#cacheBytes = 0;
|
|
106
156
|
#inflight = new Map();
|
|
157
|
+
#liveBufferPrimed = false;
|
|
107
158
|
#fetcher;
|
|
108
159
|
#maxBytes;
|
|
109
160
|
#ttlMs;
|
|
@@ -115,6 +166,7 @@ export class HlsPrefetcher {
|
|
|
115
166
|
#stripesFn;
|
|
116
167
|
#stripeChunkBytes;
|
|
117
168
|
#wholeHedgeMs;
|
|
169
|
+
#stripeHedgeMs;
|
|
118
170
|
#hits = 0;
|
|
119
171
|
#misses = 0;
|
|
120
172
|
constructor(fetcher, opts = {}) {
|
|
@@ -129,6 +181,7 @@ export class HlsPrefetcher {
|
|
|
129
181
|
this.#stripesFn = opts.stripesFn;
|
|
130
182
|
this.#stripeChunkBytes = opts.stripeChunkBytes ?? 256 * 1024;
|
|
131
183
|
this.#wholeHedgeMs = opts.wholeHedgeMs ?? WHOLE_HEDGE_MS;
|
|
184
|
+
this.#stripeHedgeMs = opts.stripeHedgeMs ?? STRIPE_HEDGE_MS;
|
|
132
185
|
}
|
|
133
186
|
/** Effective stripe count right now (dynamic if a stripesFn was provided). */
|
|
134
187
|
#stripeCount() {
|
|
@@ -244,27 +297,73 @@ export class HlsPrefetcher {
|
|
|
244
297
|
* STRIPE_TRIES exits. Returns null only if every try failed. `exactLen`, when
|
|
245
298
|
* given, rejects a 206 whose body isn't exactly that long (so assembly is
|
|
246
299
|
* byte-correct); the probe passes it undefined to accept whatever 206 arrives. */
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (
|
|
260
|
-
return
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
300
|
+
#fetchRange(u, start, end, extra, exactLen, exitHint) {
|
|
301
|
+
return new Promise((resolve) => {
|
|
302
|
+
let settled = false;
|
|
303
|
+
let launched = 0;
|
|
304
|
+
let finished = 0;
|
|
305
|
+
let hedgeTimer = null;
|
|
306
|
+
const controllers = [];
|
|
307
|
+
const clearHedge = () => { if (hedgeTimer) {
|
|
308
|
+
clearTimeout(hedgeTimer);
|
|
309
|
+
hedgeTimer = null;
|
|
310
|
+
} };
|
|
311
|
+
const finish = (r, winner) => {
|
|
312
|
+
if (settled)
|
|
313
|
+
return;
|
|
314
|
+
settled = true;
|
|
315
|
+
clearHedge();
|
|
316
|
+
for (const ac of controllers)
|
|
317
|
+
if (ac !== winner)
|
|
318
|
+
ac.abort();
|
|
319
|
+
resolve(r);
|
|
320
|
+
};
|
|
321
|
+
const scheduleHedge = () => {
|
|
322
|
+
if (settled || launched >= STRIPE_TRIES || hedgeTimer)
|
|
323
|
+
return;
|
|
324
|
+
hedgeTimer = setTimeout(() => { hedgeTimer = null; launch(); }, this.#stripeHedgeMs);
|
|
325
|
+
hedgeTimer.unref?.();
|
|
326
|
+
};
|
|
327
|
+
const launch = () => {
|
|
328
|
+
if (settled || launched >= STRIPE_TRIES)
|
|
329
|
+
return;
|
|
330
|
+
const attempt = launched++;
|
|
331
|
+
const ac = new AbortController();
|
|
332
|
+
controllers.push(ac);
|
|
333
|
+
const deadline = setTimeout(() => ac.abort(), STRIPE_DEADLINE_MS);
|
|
334
|
+
deadline.unref?.();
|
|
335
|
+
// A hedge is another connection for the SAME bytes. Router-side live
|
|
336
|
+
// ordering keeps it on the proven pool and may select another proven
|
|
337
|
+
// exit when one exists.
|
|
338
|
+
const hint = exitHint === undefined ? undefined : exitHint + attempt;
|
|
339
|
+
void this.#fetcher(u, { ...extra, host: u.host, range: `bytes=${start}-${end}` }, "stripe", ac.signal, hint)
|
|
340
|
+
.then((r) => {
|
|
341
|
+
clearTimeout(deadline);
|
|
342
|
+
if (settled)
|
|
343
|
+
return;
|
|
344
|
+
if (r.status === 200 || (r.status === 206 && (exactLen === undefined || r.body.length === exactLen))) {
|
|
345
|
+
finish(r, ac);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
failed();
|
|
349
|
+
}, () => { clearTimeout(deadline); failed(); });
|
|
350
|
+
scheduleHedge();
|
|
351
|
+
};
|
|
352
|
+
const failed = () => {
|
|
353
|
+
if (settled)
|
|
354
|
+
return;
|
|
355
|
+
finished++;
|
|
356
|
+
// A hard failure should not wait for the hedge delay.
|
|
357
|
+
if (launched < STRIPE_TRIES) {
|
|
358
|
+
clearHedge();
|
|
359
|
+
launch();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (finished >= launched)
|
|
363
|
+
finish(null);
|
|
364
|
+
};
|
|
365
|
+
launch();
|
|
366
|
+
});
|
|
268
367
|
}
|
|
269
368
|
/** Split a segment into `stripes` parallel byte-ranges across the exit pool,
|
|
270
369
|
* each hedged. Assembles them into the full body with a corrected
|
|
@@ -272,9 +371,8 @@ export class HlsPrefetcher {
|
|
|
272
371
|
* any stripe truly can't be fetched (correctness over speed). */
|
|
273
372
|
async #stripeFetch(u, extra) {
|
|
274
373
|
const stripeCount = this.#stripeCount();
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
// Just fetch the whole segment; aggregation resumes when a 2nd exit recovers.
|
|
374
|
+
// A count of one explicitly disables striping (emergency rollback or an
|
|
375
|
+
// origin/path known not to benefit from parallel range connections).
|
|
278
376
|
if (stripeCount <= 1)
|
|
279
377
|
return this.#whole(u, extra);
|
|
280
378
|
// Tiny probe (2 bytes): learn the total size + confirm the origin honors
|
|
@@ -339,6 +437,50 @@ export class HlsPrefetcher {
|
|
|
339
437
|
pending(url) {
|
|
340
438
|
return this.#inflight.get(url) ?? null;
|
|
341
439
|
}
|
|
440
|
+
/** Delay only the first response for a media-playlist URL while caching up to
|
|
441
|
+
* two of its oldest listed segments. This intentionally starts playback
|
|
442
|
+
* about 4-8 seconds behind the live edge, giving the player enough media
|
|
443
|
+
* buffer to absorb an occasional link-wide five-second slowdown. Later
|
|
444
|
+
* refreshes are never delayed. A fetch that outlives the wait remains tracked
|
|
445
|
+
* so the player's segment request joins it instead of downloading twice. */
|
|
446
|
+
async primePlaylist(playlistUrl, body, maxWaitMs = 8000, targetSegments = 2) {
|
|
447
|
+
const segs = parseM3u8Segments(body, playlistUrl).slice(-this.#ahead);
|
|
448
|
+
if (segs.length === 0)
|
|
449
|
+
return 0;
|
|
450
|
+
// One global prime only. CCTV probes/switches several variant and backup-CDN
|
|
451
|
+
// playlist URLs during startup; priming each URL independently launched
|
|
452
|
+
// 600/1350/576/1800 segments together and created the very congestion this
|
|
453
|
+
// buffer is intended to absorb.
|
|
454
|
+
if (this.#liveBufferPrimed) {
|
|
455
|
+
this.onPlaylist(playlistUrl, body);
|
|
456
|
+
return 0;
|
|
457
|
+
}
|
|
458
|
+
this.#liveBufferPrimed = true;
|
|
459
|
+
const deadline = Date.now() + Math.max(0, maxWaitMs);
|
|
460
|
+
let ready = 0;
|
|
461
|
+
for (const url of segs.slice(0, Math.max(1, targetSegments))) {
|
|
462
|
+
if (this.#peek(url)) {
|
|
463
|
+
ready++;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const remaining = deadline - Date.now();
|
|
467
|
+
if (remaining <= 0)
|
|
468
|
+
break;
|
|
469
|
+
const timeout = new Promise((resolve) => {
|
|
470
|
+
const t = setTimeout(() => resolve(null), remaining);
|
|
471
|
+
t.unref?.();
|
|
472
|
+
});
|
|
473
|
+
const fetched = await Promise.race([
|
|
474
|
+
this.fetchSegmentStriped(url).then((r) => r.status === 200 && r.body.length > 0 ? r : null),
|
|
475
|
+
timeout,
|
|
476
|
+
]);
|
|
477
|
+
if (!fetched)
|
|
478
|
+
break;
|
|
479
|
+
ready++;
|
|
480
|
+
}
|
|
481
|
+
this.onPlaylist(playlistUrl, body);
|
|
482
|
+
return ready;
|
|
483
|
+
}
|
|
342
484
|
#peek(url) {
|
|
343
485
|
const e = this.#cache.get(url);
|
|
344
486
|
if (!e)
|
|
@@ -21,7 +21,7 @@ import https from "node:https";
|
|
|
21
21
|
import net from "node:net";
|
|
22
22
|
import tls from "node:tls";
|
|
23
23
|
import { egressConnect } from "./egress.js";
|
|
24
|
-
import { HlsPrefetcher, looksLikePlaylist, looksLikeSegment, parseM3u8Variants, parseM3u8Segments } from "./hls-prefetch.js";
|
|
24
|
+
import { HlsPrefetcher, buildM3u8VariantFallbacks, capM3u8Variants, looksLikePlaylist, looksLikeSegment, parseM3u8Variants, parseM3u8Segments } from "./hls-prefetch.js";
|
|
25
25
|
import { RouteLearner } from "./route-learner.js";
|
|
26
26
|
import { GeoCnClassifier } from "./geo-cn.js";
|
|
27
27
|
import { MitmGateway } from "./mitm-gateway.js";
|
|
@@ -155,7 +155,15 @@ const NEAR_DEAD_EXIT_BPS = 60_000;
|
|
|
155
155
|
// buffer. Keep such exits available for last-resort failover, but do not count
|
|
156
156
|
// them as HLS delivery capacity or let a slow redemption probe re-enable them.
|
|
157
157
|
// This is bytes/sec (150 KB/s = 1.2 Mbit/s).
|
|
158
|
-
|
|
158
|
+
// A foreground HD exit must sustain at least 1.6 Mbit/s. The former 1.2 Mbit/s
|
|
159
|
+
// threshold admitted 10.86.15.133 on a short peak even though its full CCTV
|
|
160
|
+
// segments averaged only 0.7-1.2 Mbit/s, causing each half-range to take 4-6s.
|
|
161
|
+
const HLS_DELIVERY_BPS = 200_000;
|
|
162
|
+
// Stability-first default for constrained exit pools. Set 0 to expose every
|
|
163
|
+
// origin variant, or 1800000 to restore CCTV 720P when another fast exit joins.
|
|
164
|
+
const HLS_MAX_VARIANT_BPS = process.env.AGENTNET_HLS_MAX_BPS === undefined
|
|
165
|
+
? 900_000
|
|
166
|
+
: Math.max(0, Number(process.env.AGENTNET_HLS_MAX_BPS));
|
|
159
167
|
export function isUsefulRecoverySample(peakBps, averageBps, minBps = HLS_DELIVERY_BPS) {
|
|
160
168
|
return peakBps >= minBps && averageBps >= minBps;
|
|
161
169
|
}
|
|
@@ -262,9 +270,26 @@ function sampleSpeed(exit, meter, durMs, recoveryMinBps = NEAR_DEAD_EXIT_BPS) {
|
|
|
262
270
|
* exit is degraded, keep the original pool as a last resort. Exported for the
|
|
263
271
|
* policy regression tests. */
|
|
264
272
|
export function selectHlsPrimaryPool(pool) {
|
|
273
|
+
const fastestTier = (candidates) => {
|
|
274
|
+
const best = Math.max(...candidates.map((e) => e.speedEwma ?? 0));
|
|
275
|
+
return candidates.filter((e) => (e.speedEwma ?? 0) >= best * 0.8);
|
|
276
|
+
};
|
|
265
277
|
const proven = pool.filter((e) => e.flooredAtMs === null && e.speedEwma !== null && e.speedEwma >= HLS_DELIVERY_BPS);
|
|
266
|
-
if (proven.length > 0)
|
|
267
|
-
|
|
278
|
+
if (proven.length > 0) {
|
|
279
|
+
// A fixed floor alone is not enough: a marginal path can briefly cross it
|
|
280
|
+
// while remaining far slower than the best live path, then hold one stripe
|
|
281
|
+
// hostage. Keep only the fastest tier (within 80% of the current best).
|
|
282
|
+
// Similar-speed exits can still aggregate; a clearly slower one is reserved
|
|
283
|
+
// for background redemption/failover.
|
|
284
|
+
return fastestTier(proven);
|
|
285
|
+
}
|
|
286
|
+
// If every measured path is below the HD floor, the best measured path is
|
|
287
|
+
// still safer than falling back to every unproven/degraded exit. This is the
|
|
288
|
+
// adaptive-bitrate path: the master playlist is capped separately, while
|
|
289
|
+
// foreground delivery stays on the least-bad known tier.
|
|
290
|
+
const measured = pool.filter((e) => e.flooredAtMs === null && e.speedEwma !== null);
|
|
291
|
+
if (measured.length > 0)
|
|
292
|
+
return fastestTier(measured);
|
|
268
293
|
const unproven = pool.filter((e) => e.flooredAtMs === null && e.speedEwma === null);
|
|
269
294
|
return unproven.length > 0 ? unproven : pool;
|
|
270
295
|
}
|
|
@@ -1174,24 +1199,22 @@ export function startMultiExitRouter(opts) {
|
|
|
1174
1199
|
// exists, so a segment/stripe is never handed to a 0.1 Mbps exit that would
|
|
1175
1200
|
// stall the player. Unproven exits are kept (they get a chance; the caller's
|
|
1176
1201
|
// hedge covers them if they turn out dead).
|
|
1177
|
-
//
|
|
1178
|
-
//
|
|
1179
|
-
//
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1202
|
+
// Foreground HLS traffic must stay on the proven delivery pool. In
|
|
1203
|
+
// particular, a stripe must not borrow a marginal/unproven exit merely to
|
|
1204
|
+
// obtain a second path: one 0.1 Mbps half-range holds the entire assembled
|
|
1205
|
+
// segment hostage. Multiple range connections may still share one proven
|
|
1206
|
+
// exit; that is intentional because the observed long tail is per
|
|
1207
|
+
// connection and the CDN supports byte ranges.
|
|
1182
1208
|
let order;
|
|
1183
1209
|
if (forcedExit) {
|
|
1184
1210
|
order = base0;
|
|
1185
1211
|
}
|
|
1186
1212
|
else if (exitHint !== undefined) {
|
|
1187
|
-
//
|
|
1188
|
-
//
|
|
1189
|
-
//
|
|
1190
|
-
//
|
|
1191
|
-
|
|
1192
|
-
// explicit exitHint pin fought this: base0 reorders under load, so the
|
|
1193
|
-
// pinned index kept landing back on the same fast exit.)
|
|
1194
|
-
order = base0;
|
|
1213
|
+
// Keep the live load-aware ordering, but filter it through the same
|
|
1214
|
+
// foreground-safe policy as whole segments. With one proven exit both
|
|
1215
|
+
// stripes use separate connections through it; with two proven exits
|
|
1216
|
+
// active-count ordering naturally spreads the stripes.
|
|
1217
|
+
order = selectHlsPrimaryPool(base0);
|
|
1195
1218
|
}
|
|
1196
1219
|
else {
|
|
1197
1220
|
// Load-aware order for whole fetches too: exit.active is incremented at
|
|
@@ -1348,7 +1371,7 @@ export function startMultiExitRouter(opts) {
|
|
|
1348
1371
|
// it. Player requests JOIN an in-flight prefetch (dedup) so bandwidth isn't
|
|
1349
1372
|
// split fetching the same segment twice. concurrency:2 keeps at most 2
|
|
1350
1373
|
// segments (≈6 stripes over ~3 exits) in flight so the pool isn't overwhelmed.
|
|
1351
|
-
const AGG = process.env.AGENTNET_HLS_STRIPES ? Math.max(1, Number(process.env.AGENTNET_HLS_STRIPES)) :
|
|
1374
|
+
const AGG = process.env.AGENTNET_HLS_STRIPES ? Math.max(1, Number(process.env.AGENTNET_HLS_STRIPES)) : 3;
|
|
1352
1375
|
// How many china exits are ACTUALLY delivering right now — healthy (or with
|
|
1353
1376
|
// live traffic), not tripped, and not speed-penalized (an exit that returned
|
|
1354
1377
|
// empty/0-byte gets its score floored, so it drops out here until it serves
|
|
@@ -1363,7 +1386,23 @@ export function startMultiExitRouter(opts) {
|
|
|
1363
1386
|
(e.healthy || (e.lastSuccessMs !== null && now - e.lastSuccessMs < RECENT_SUCCESS_MS)));
|
|
1364
1387
|
return countHlsDeliveryExits(eligible);
|
|
1365
1388
|
};
|
|
1366
|
-
const
|
|
1389
|
+
const hasMeasuredChinaExit = () => {
|
|
1390
|
+
if (!fallbackRegion)
|
|
1391
|
+
return false;
|
|
1392
|
+
const now = Date.now();
|
|
1393
|
+
return exits.some((e) => e.region === fallbackRegion && e.trippedUntil <= now &&
|
|
1394
|
+
e.flooredAtMs === null && e.speedEwma !== null);
|
|
1395
|
+
};
|
|
1396
|
+
// Stripe count is a connection count, not a distinct-exit count. Keep three
|
|
1397
|
+
// parallel ranges even when only one exit is qualified: two ranges still
|
|
1398
|
+
// delivered CCTV's 1800 stream in 3.6-4.9s, which is marginal for a 4s live
|
|
1399
|
+
// segment, while one connection repeatedly showed 6-10s tails. During
|
|
1400
|
+
// startup, keep whole-segment mode until at least one
|
|
1401
|
+
// exit has delivered a real fast sample; otherwise the unproven startup pool
|
|
1402
|
+
// would scatter half-ranges onto every slow exit and hold assembly hostage.
|
|
1403
|
+
// Set AGENTNET_HLS_STRIPES=1 for an emergency rollback.
|
|
1404
|
+
const usableChinaExits = () => AGG <= 1 || !hasMeasuredChinaExit() ? 1 : AGG;
|
|
1405
|
+
const hlsConcurrency = () => hasMeasuredChinaExit() ? deliveringChinaExits() : 1;
|
|
1367
1406
|
// Prefetch parallelism = the number of exits ACTUALLY delivering right now
|
|
1368
1407
|
// (deliveringChinaExits). Each concurrent prefetch is a whole-segment fetch,
|
|
1369
1408
|
// and the load-aware exit pick (active++ at dispatch) lands each one on a
|
|
@@ -1372,7 +1411,9 @@ export function startMultiExitRouter(opts) {
|
|
|
1372
1411
|
// degrades to one-at-a-time (full bandwidth per segment, no splitting —
|
|
1373
1412
|
// several fetches on ONE exit divide it N ways and everything stalls).
|
|
1374
1413
|
// prefetchAhead=3 matches the 3-segment live window CCTV publishes.
|
|
1375
|
-
const hls = new HlsPrefetcher(fetchViaRegionBuffered, { stripes: AGG, stripesFn: usableChinaExits, stripeChunkBytes: 256 * 1024, concurrencyFn:
|
|
1414
|
+
const hls = new HlsPrefetcher(fetchViaRegionBuffered, { stripes: AGG, stripesFn: usableChinaExits, stripeChunkBytes: 256 * 1024, concurrencyFn: hlsConcurrency, prefetchAhead: 3 });
|
|
1415
|
+
const hlsVariantFallbacks = new Map();
|
|
1416
|
+
const loggedVariantFallbacks = new Set();
|
|
1376
1417
|
let lastHlsHits = 0;
|
|
1377
1418
|
function bumpHlsStats() {
|
|
1378
1419
|
const s = hls.stats;
|
|
@@ -1389,7 +1430,16 @@ export function startMultiExitRouter(opts) {
|
|
|
1389
1430
|
// else streams through one exit.
|
|
1390
1431
|
function serveHlsAware(o) {
|
|
1391
1432
|
const { fullUrl, host, port, path, target, method, headers, req, res, order, isTls } = o;
|
|
1392
|
-
|
|
1433
|
+
// A player may retain a high-bitrate media-playlist URL after the capped
|
|
1434
|
+
// master has been reloaded. Fetch the best allowed variant on its behalf;
|
|
1435
|
+
// the returned media playlist contains the safe segment names, so the next
|
|
1436
|
+
// request naturally moves to the lower bitrate without a page reload.
|
|
1437
|
+
const effectiveFullUrl = hlsVariantFallbacks.get(fullUrl) ?? fullUrl;
|
|
1438
|
+
const u = new URL(effectiveFullUrl);
|
|
1439
|
+
if (effectiveFullUrl !== fullUrl && !loggedVariantFallbacks.has(fullUrl)) {
|
|
1440
|
+
loggedVariantFallbacks.add(fullUrl);
|
|
1441
|
+
log(` HLS variant fallback ${fullUrl} → ${effectiveFullUrl}`);
|
|
1442
|
+
}
|
|
1393
1443
|
// The CCTV H5 player fetches segments via XHR/fetch (HLSP2P/DRM), so the
|
|
1394
1444
|
// browser enforces CORS. The CDN sends Access-Control-Allow-Origin, but our
|
|
1395
1445
|
// cache-hit and dedup-join serving paths only carried content-type — dropping
|
|
@@ -1452,7 +1502,34 @@ export function startMultiExitRouter(opts) {
|
|
|
1452
1502
|
new Promise((_, rej) => { const t = setTimeout(() => rej(new Error("segment total deadline")), SEGMENT_TOTAL_DEADLINE_MS); t.unref?.(); }),
|
|
1453
1503
|
]);
|
|
1454
1504
|
fetchPromise
|
|
1455
|
-
.then((r) => {
|
|
1505
|
+
.then(async (r) => {
|
|
1506
|
+
let playlistText = null;
|
|
1507
|
+
let playlistVariants = [];
|
|
1508
|
+
if (r.status === 200 && isPlaylist) {
|
|
1509
|
+
playlistText = r.body.toString("utf8");
|
|
1510
|
+
playlistVariants = parseM3u8Variants(playlistText, effectiveFullUrl);
|
|
1511
|
+
if (playlistVariants.length > 0 && HLS_MAX_VARIANT_BPS > 0) {
|
|
1512
|
+
const originalCount = playlistVariants.length;
|
|
1513
|
+
for (const [blocked, fallback] of buildM3u8VariantFallbacks(playlistText, effectiveFullUrl, HLS_MAX_VARIANT_BPS)) {
|
|
1514
|
+
hlsVariantFallbacks.set(blocked, fallback);
|
|
1515
|
+
}
|
|
1516
|
+
const capped = capM3u8Variants(playlistText, HLS_MAX_VARIANT_BPS);
|
|
1517
|
+
if (capped !== playlistText) {
|
|
1518
|
+
playlistText = capped;
|
|
1519
|
+
r.body = Buffer.from(capped, "utf8");
|
|
1520
|
+
playlistVariants = parseM3u8Variants(capped, effectiveFullUrl);
|
|
1521
|
+
log(` playlist ${host}: capped ${originalCount} variants to ${playlistVariants.length} at ${(HLS_MAX_VARIANT_BPS / 1e6).toFixed(1)}Mbps`);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
else if (playlistVariants.length === 0) {
|
|
1525
|
+
const segs = parseM3u8Segments(playlistText, effectiveFullUrl);
|
|
1526
|
+
const primed = await hls.primePlaylist(effectiveFullUrl, playlistText, 8000, 2);
|
|
1527
|
+
if (primed > 0)
|
|
1528
|
+
log(` playlist ${host}: ${primed} segment(s) cached before first delivery`);
|
|
1529
|
+
else
|
|
1530
|
+
log(` playlist ${host} is MEDIA — ${segs.length} segments, prefetching ahead`);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1456
1533
|
log(` MITM ${method} ${host}${(path || "/").slice(0, 80)} → ${r.status} (${r.body.length}B)`);
|
|
1457
1534
|
if (r.status >= 400) {
|
|
1458
1535
|
const snip = r.body.slice(0, 300).toString("utf8").replace(/\s+/g, " ");
|
|
@@ -1467,17 +1544,13 @@ export function startMultiExitRouter(opts) {
|
|
|
1467
1544
|
if (r.status === 200) {
|
|
1468
1545
|
const ct = r.headers["content-type"];
|
|
1469
1546
|
if (isPlaylist) {
|
|
1470
|
-
const text = r.body.toString("utf8");
|
|
1471
|
-
const variants = parseM3u8Variants(text,
|
|
1547
|
+
const text = playlistText ?? r.body.toString("utf8");
|
|
1548
|
+
const variants = playlistVariants.length > 0 ? playlistVariants : parseM3u8Variants(text, effectiveFullUrl);
|
|
1472
1549
|
if (variants.length > 0) {
|
|
1473
1550
|
const desc = variants.map((v) => `${v.resolution || "?"}@${(v.bandwidth / 1e6).toFixed(1)}Mbps`).join(", ");
|
|
1474
1551
|
log(` playlist ${host} is MASTER — variants: ${desc}`);
|
|
1552
|
+
hls.onPlaylist(effectiveFullUrl, text);
|
|
1475
1553
|
}
|
|
1476
|
-
else {
|
|
1477
|
-
const segs = parseM3u8Segments(text, fullUrl);
|
|
1478
|
-
log(` playlist ${host} is MEDIA — ${segs.length} segments, prefetching ahead`);
|
|
1479
|
-
}
|
|
1480
|
-
hls.onPlaylist(fullUrl, text);
|
|
1481
1554
|
}
|
|
1482
1555
|
else {
|
|
1483
1556
|
hls.put(fullUrl, r.body, Array.isArray(ct) ? ct[0] : ct);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.195",
|
|
4
4
|
"description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|