@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.
Binary file
Binary file
Binary file
Binary file
@@ -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
- console.log("Windows: import the cert into 'Trusted Root Certification Authorities':");
1435
- console.log(` certutil -addstore -f ROOT "${caCertPath}"`);
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: returns how many exits are ACTUALLY delivering right
57
- * now, so a segment is split only as many ways as there are working exits.
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
- async #fetchRange(u, start, end, extra, exactLen, exitHint) {
248
- for (let t = 0; t < STRIPE_TRIES; t++) {
249
- const ac = new AbortController();
250
- const timer = setTimeout(() => ac.abort(), STRIPE_DEADLINE_MS);
251
- timer.unref?.();
252
- try {
253
- // Each retry shifts to the NEXT exit so a failed range moves off a bad one.
254
- const hint = exitHint === undefined ? undefined : exitHint + t;
255
- const r = await this.#fetcher(u, { ...extra, host: u.host, range: `bytes=${start}-${end}` }, "stripe", ac.signal, hint);
256
- clearTimeout(timer);
257
- if (r.status === 200)
258
- return r; // origin ignored Range → whole body
259
- if (r.status === 206 && (exactLen === undefined || r.body.length === exactLen))
260
- return r;
261
- // wrong status/length → try another exit
262
- }
263
- catch {
264
- clearTimeout(timer);
265
- }
266
- }
267
- return null;
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
- // Only ONE exit is delivering right now → striping would split the segment
276
- // onto a single exit that serializes the parts (14s vs a 2.4s whole fetch).
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
- const HLS_DELIVERY_BPS = 150_000;
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
- return proven;
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
- // A striped range (exitHint set) pins to a DISTINCT exit across the FULL
1178
- // healthy pool (base0, already fastest-first, tripped exits excluded) so
1179
- // parallel ranges spread over ALL usable exits and aggregate even the
1180
- // moderately-slow ones (0.4–0.6 Mbps) that we WANT for aggregation. A
1181
- // single (non-striped) fetch instead skips near-dead exits and rotates.
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
- // Striped range: use pickOrder's live LOAD-AWARE order directly (base0 is
1188
- // sorted by speed/(active+1), least-loaded-fastest first). Because each
1189
- // buffered fetch now increments exit.active at connect time, concurrent
1190
- // stripes each see the prior stripe's load and pick the NEXT exit —
1191
- // spreading across DISTINCT exits and aggregating their bandwidth. (An
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)) : 1;
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 usableChinaExits = () => Math.min(AGG, deliveringChinaExits());
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: deliveringChinaExits, prefetchAhead: 3 });
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
- const u = new URL(fullUrl);
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, fullUrl);
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.193",
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",