@decentnetwork/lan 0.1.193 → 0.1.194
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
|
|
@@ -20,6 +20,10 @@ 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;
|
|
23
27
|
/**
|
|
24
28
|
* Extract the media-segment URLs from an m3u8 playlist, resolved against the
|
|
25
29
|
* playlist's own URL. Returns [] for master playlists (variant lists) — those
|
|
@@ -53,15 +57,15 @@ export interface HlsPrefetcherOptions {
|
|
|
53
57
|
* exit pool (default 4). 1 disables striping. Lets ONE segment ride the SUM
|
|
54
58
|
* of the exits' bandwidth instead of a single throttled connection. */
|
|
55
59
|
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. */
|
|
60
|
+
/** Dynamic stripe count: may reflect either distinct exits or parallel
|
|
61
|
+
* connections on one proven exit. Overrides `stripes` when provided. */
|
|
61
62
|
stripesFn?: () => number;
|
|
62
63
|
/** Delay before duplicating a slow whole-segment fetch. Primarily exposed for
|
|
63
64
|
* deterministic tests; default 3.5s is just below CCTV's 4s live segment. */
|
|
64
65
|
wholeHedgeMs?: number;
|
|
66
|
+
/** Delay before duplicating a slow byte range. Exposed for deterministic
|
|
67
|
+
* tests; default 2s leaves time for the rescue to beat a 4s live segment. */
|
|
68
|
+
stripeHedgeMs?: number;
|
|
65
69
|
/** Stripe chunk size — also the first probe chunk (default 256 KB). Segments
|
|
66
70
|
* smaller than this aren't striped. */
|
|
67
71
|
stripeChunkBytes?: number;
|
|
@@ -89,6 +93,13 @@ export declare class HlsPrefetcher {
|
|
|
89
93
|
* lands — lets a player request "join" an in-flight prefetch instead of
|
|
90
94
|
* double-downloading. */
|
|
91
95
|
pending(url: string): Promise<CacheEntry | null> | null;
|
|
96
|
+
/** Delay only the first response for a media-playlist URL while caching up to
|
|
97
|
+
* two of its oldest listed segments. This intentionally starts playback
|
|
98
|
+
* about 4-8 seconds behind the live edge, giving the player enough media
|
|
99
|
+
* buffer to absorb an occasional link-wide five-second slowdown. Later
|
|
100
|
+
* refreshes are never delayed. A fetch that outlives the wait remains tracked
|
|
101
|
+
* so the player's segment request joins it instead of downloading twice. */
|
|
102
|
+
primePlaylist(playlistUrl: string, body: string, maxWaitMs?: number, targetSegments?: number): Promise<number>;
|
|
92
103
|
/** Called with every playlist body that passes through the router: parse it
|
|
93
104
|
* and top up the prefetch pipeline for its upcoming segments. */
|
|
94
105
|
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,32 @@ 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
|
+
}
|
|
81
110
|
/**
|
|
82
111
|
* Extract the media-segment URLs from an m3u8 playlist, resolved against the
|
|
83
112
|
* playlist's own URL. Returns [] for master playlists (variant lists) — those
|
|
@@ -104,6 +133,7 @@ export class HlsPrefetcher {
|
|
|
104
133
|
#cache = new Map(); // Map preserves insertion order → LRU-ish
|
|
105
134
|
#cacheBytes = 0;
|
|
106
135
|
#inflight = new Map();
|
|
136
|
+
#liveBufferPrimed = false;
|
|
107
137
|
#fetcher;
|
|
108
138
|
#maxBytes;
|
|
109
139
|
#ttlMs;
|
|
@@ -115,6 +145,7 @@ export class HlsPrefetcher {
|
|
|
115
145
|
#stripesFn;
|
|
116
146
|
#stripeChunkBytes;
|
|
117
147
|
#wholeHedgeMs;
|
|
148
|
+
#stripeHedgeMs;
|
|
118
149
|
#hits = 0;
|
|
119
150
|
#misses = 0;
|
|
120
151
|
constructor(fetcher, opts = {}) {
|
|
@@ -129,6 +160,7 @@ export class HlsPrefetcher {
|
|
|
129
160
|
this.#stripesFn = opts.stripesFn;
|
|
130
161
|
this.#stripeChunkBytes = opts.stripeChunkBytes ?? 256 * 1024;
|
|
131
162
|
this.#wholeHedgeMs = opts.wholeHedgeMs ?? WHOLE_HEDGE_MS;
|
|
163
|
+
this.#stripeHedgeMs = opts.stripeHedgeMs ?? STRIPE_HEDGE_MS;
|
|
132
164
|
}
|
|
133
165
|
/** Effective stripe count right now (dynamic if a stripesFn was provided). */
|
|
134
166
|
#stripeCount() {
|
|
@@ -244,27 +276,73 @@ export class HlsPrefetcher {
|
|
|
244
276
|
* STRIPE_TRIES exits. Returns null only if every try failed. `exactLen`, when
|
|
245
277
|
* given, rejects a 206 whose body isn't exactly that long (so assembly is
|
|
246
278
|
* 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
|
-
|
|
279
|
+
#fetchRange(u, start, end, extra, exactLen, exitHint) {
|
|
280
|
+
return new Promise((resolve) => {
|
|
281
|
+
let settled = false;
|
|
282
|
+
let launched = 0;
|
|
283
|
+
let finished = 0;
|
|
284
|
+
let hedgeTimer = null;
|
|
285
|
+
const controllers = [];
|
|
286
|
+
const clearHedge = () => { if (hedgeTimer) {
|
|
287
|
+
clearTimeout(hedgeTimer);
|
|
288
|
+
hedgeTimer = null;
|
|
289
|
+
} };
|
|
290
|
+
const finish = (r, winner) => {
|
|
291
|
+
if (settled)
|
|
292
|
+
return;
|
|
293
|
+
settled = true;
|
|
294
|
+
clearHedge();
|
|
295
|
+
for (const ac of controllers)
|
|
296
|
+
if (ac !== winner)
|
|
297
|
+
ac.abort();
|
|
298
|
+
resolve(r);
|
|
299
|
+
};
|
|
300
|
+
const scheduleHedge = () => {
|
|
301
|
+
if (settled || launched >= STRIPE_TRIES || hedgeTimer)
|
|
302
|
+
return;
|
|
303
|
+
hedgeTimer = setTimeout(() => { hedgeTimer = null; launch(); }, this.#stripeHedgeMs);
|
|
304
|
+
hedgeTimer.unref?.();
|
|
305
|
+
};
|
|
306
|
+
const launch = () => {
|
|
307
|
+
if (settled || launched >= STRIPE_TRIES)
|
|
308
|
+
return;
|
|
309
|
+
const attempt = launched++;
|
|
310
|
+
const ac = new AbortController();
|
|
311
|
+
controllers.push(ac);
|
|
312
|
+
const deadline = setTimeout(() => ac.abort(), STRIPE_DEADLINE_MS);
|
|
313
|
+
deadline.unref?.();
|
|
314
|
+
// A hedge is another connection for the SAME bytes. Router-side live
|
|
315
|
+
// ordering keeps it on the proven pool and may select another proven
|
|
316
|
+
// exit when one exists.
|
|
317
|
+
const hint = exitHint === undefined ? undefined : exitHint + attempt;
|
|
318
|
+
void this.#fetcher(u, { ...extra, host: u.host, range: `bytes=${start}-${end}` }, "stripe", ac.signal, hint)
|
|
319
|
+
.then((r) => {
|
|
320
|
+
clearTimeout(deadline);
|
|
321
|
+
if (settled)
|
|
322
|
+
return;
|
|
323
|
+
if (r.status === 200 || (r.status === 206 && (exactLen === undefined || r.body.length === exactLen))) {
|
|
324
|
+
finish(r, ac);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
failed();
|
|
328
|
+
}, () => { clearTimeout(deadline); failed(); });
|
|
329
|
+
scheduleHedge();
|
|
330
|
+
};
|
|
331
|
+
const failed = () => {
|
|
332
|
+
if (settled)
|
|
333
|
+
return;
|
|
334
|
+
finished++;
|
|
335
|
+
// A hard failure should not wait for the hedge delay.
|
|
336
|
+
if (launched < STRIPE_TRIES) {
|
|
337
|
+
clearHedge();
|
|
338
|
+
launch();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (finished >= launched)
|
|
342
|
+
finish(null);
|
|
343
|
+
};
|
|
344
|
+
launch();
|
|
345
|
+
});
|
|
268
346
|
}
|
|
269
347
|
/** Split a segment into `stripes` parallel byte-ranges across the exit pool,
|
|
270
348
|
* each hedged. Assembles them into the full body with a corrected
|
|
@@ -272,9 +350,8 @@ export class HlsPrefetcher {
|
|
|
272
350
|
* any stripe truly can't be fetched (correctness over speed). */
|
|
273
351
|
async #stripeFetch(u, extra) {
|
|
274
352
|
const stripeCount = this.#stripeCount();
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
// Just fetch the whole segment; aggregation resumes when a 2nd exit recovers.
|
|
353
|
+
// A count of one explicitly disables striping (emergency rollback or an
|
|
354
|
+
// origin/path known not to benefit from parallel range connections).
|
|
278
355
|
if (stripeCount <= 1)
|
|
279
356
|
return this.#whole(u, extra);
|
|
280
357
|
// Tiny probe (2 bytes): learn the total size + confirm the origin honors
|
|
@@ -339,6 +416,50 @@ export class HlsPrefetcher {
|
|
|
339
416
|
pending(url) {
|
|
340
417
|
return this.#inflight.get(url) ?? null;
|
|
341
418
|
}
|
|
419
|
+
/** Delay only the first response for a media-playlist URL while caching up to
|
|
420
|
+
* two of its oldest listed segments. This intentionally starts playback
|
|
421
|
+
* about 4-8 seconds behind the live edge, giving the player enough media
|
|
422
|
+
* buffer to absorb an occasional link-wide five-second slowdown. Later
|
|
423
|
+
* refreshes are never delayed. A fetch that outlives the wait remains tracked
|
|
424
|
+
* so the player's segment request joins it instead of downloading twice. */
|
|
425
|
+
async primePlaylist(playlistUrl, body, maxWaitMs = 8000, targetSegments = 2) {
|
|
426
|
+
const segs = parseM3u8Segments(body, playlistUrl).slice(-this.#ahead);
|
|
427
|
+
if (segs.length === 0)
|
|
428
|
+
return 0;
|
|
429
|
+
// One global prime only. CCTV probes/switches several variant and backup-CDN
|
|
430
|
+
// playlist URLs during startup; priming each URL independently launched
|
|
431
|
+
// 600/1350/576/1800 segments together and created the very congestion this
|
|
432
|
+
// buffer is intended to absorb.
|
|
433
|
+
if (this.#liveBufferPrimed) {
|
|
434
|
+
this.onPlaylist(playlistUrl, body);
|
|
435
|
+
return 0;
|
|
436
|
+
}
|
|
437
|
+
this.#liveBufferPrimed = true;
|
|
438
|
+
const deadline = Date.now() + Math.max(0, maxWaitMs);
|
|
439
|
+
let ready = 0;
|
|
440
|
+
for (const url of segs.slice(0, Math.max(1, targetSegments))) {
|
|
441
|
+
if (this.#peek(url)) {
|
|
442
|
+
ready++;
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const remaining = deadline - Date.now();
|
|
446
|
+
if (remaining <= 0)
|
|
447
|
+
break;
|
|
448
|
+
const timeout = new Promise((resolve) => {
|
|
449
|
+
const t = setTimeout(() => resolve(null), remaining);
|
|
450
|
+
t.unref?.();
|
|
451
|
+
});
|
|
452
|
+
const fetched = await Promise.race([
|
|
453
|
+
this.fetchSegmentStriped(url).then((r) => r.status === 200 && r.body.length > 0 ? r : null),
|
|
454
|
+
timeout,
|
|
455
|
+
]);
|
|
456
|
+
if (!fetched)
|
|
457
|
+
break;
|
|
458
|
+
ready++;
|
|
459
|
+
}
|
|
460
|
+
this.onPlaylist(playlistUrl, body);
|
|
461
|
+
return ready;
|
|
462
|
+
}
|
|
342
463
|
#peek(url) {
|
|
343
464
|
const e = this.#cache.get(url);
|
|
344
465
|
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, 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
|
+
? 1_400_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,7 @@ 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 });
|
|
1376
1415
|
let lastHlsHits = 0;
|
|
1377
1416
|
function bumpHlsStats() {
|
|
1378
1417
|
const s = hls.stats;
|
|
@@ -1452,7 +1491,31 @@ export function startMultiExitRouter(opts) {
|
|
|
1452
1491
|
new Promise((_, rej) => { const t = setTimeout(() => rej(new Error("segment total deadline")), SEGMENT_TOTAL_DEADLINE_MS); t.unref?.(); }),
|
|
1453
1492
|
]);
|
|
1454
1493
|
fetchPromise
|
|
1455
|
-
.then((r) => {
|
|
1494
|
+
.then(async (r) => {
|
|
1495
|
+
let playlistText = null;
|
|
1496
|
+
let playlistVariants = [];
|
|
1497
|
+
if (r.status === 200 && isPlaylist) {
|
|
1498
|
+
playlistText = r.body.toString("utf8");
|
|
1499
|
+
playlistVariants = parseM3u8Variants(playlistText, fullUrl);
|
|
1500
|
+
if (playlistVariants.length > 0 && HLS_MAX_VARIANT_BPS > 0) {
|
|
1501
|
+
const originalCount = playlistVariants.length;
|
|
1502
|
+
const capped = capM3u8Variants(playlistText, HLS_MAX_VARIANT_BPS);
|
|
1503
|
+
if (capped !== playlistText) {
|
|
1504
|
+
playlistText = capped;
|
|
1505
|
+
r.body = Buffer.from(capped, "utf8");
|
|
1506
|
+
playlistVariants = parseM3u8Variants(capped, fullUrl);
|
|
1507
|
+
log(` playlist ${host}: capped ${originalCount} variants to ${playlistVariants.length} at ${(HLS_MAX_VARIANT_BPS / 1e6).toFixed(1)}Mbps`);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
else if (playlistVariants.length === 0) {
|
|
1511
|
+
const segs = parseM3u8Segments(playlistText, fullUrl);
|
|
1512
|
+
const primed = await hls.primePlaylist(fullUrl, playlistText, 8000, 2);
|
|
1513
|
+
if (primed > 0)
|
|
1514
|
+
log(` playlist ${host}: ${primed} segment(s) cached before first delivery`);
|
|
1515
|
+
else
|
|
1516
|
+
log(` playlist ${host} is MEDIA — ${segs.length} segments, prefetching ahead`);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1456
1519
|
log(` MITM ${method} ${host}${(path || "/").slice(0, 80)} → ${r.status} (${r.body.length}B)`);
|
|
1457
1520
|
if (r.status >= 400) {
|
|
1458
1521
|
const snip = r.body.slice(0, 300).toString("utf8").replace(/\s+/g, " ");
|
|
@@ -1467,17 +1530,13 @@ export function startMultiExitRouter(opts) {
|
|
|
1467
1530
|
if (r.status === 200) {
|
|
1468
1531
|
const ct = r.headers["content-type"];
|
|
1469
1532
|
if (isPlaylist) {
|
|
1470
|
-
const text = r.body.toString("utf8");
|
|
1471
|
-
const variants = parseM3u8Variants(text, fullUrl);
|
|
1533
|
+
const text = playlistText ?? r.body.toString("utf8");
|
|
1534
|
+
const variants = playlistVariants.length > 0 ? playlistVariants : parseM3u8Variants(text, fullUrl);
|
|
1472
1535
|
if (variants.length > 0) {
|
|
1473
1536
|
const desc = variants.map((v) => `${v.resolution || "?"}@${(v.bandwidth / 1e6).toFixed(1)}Mbps`).join(", ");
|
|
1474
1537
|
log(` playlist ${host} is MASTER — variants: ${desc}`);
|
|
1538
|
+
hls.onPlaylist(fullUrl, text);
|
|
1475
1539
|
}
|
|
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
1540
|
}
|
|
1482
1541
|
else {
|
|
1483
1542
|
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.194",
|
|
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",
|