@decentnetwork/lan 0.1.196 → 0.1.198
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/proxy/hls-prefetch.d.ts +8 -0
- package/dist/proxy/hls-prefetch.js +103 -15
- package/dist/proxy/multi-exit-router.d.ts +1 -0
- package/dist/proxy/multi-exit-router.js +108 -37
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -29,6 +29,14 @@ export declare function capM3u8Variants(playlist: string, maxBandwidth: number):
|
|
|
29
29
|
* reloading the capped master playlist. The proxy can use this map to fetch the
|
|
30
30
|
* safe media playlist while preserving the browser-facing request URL. */
|
|
31
31
|
export declare function buildM3u8VariantFallbacks(playlist: string, baseUrl: string, maxBandwidth: number): Map<string, string>;
|
|
32
|
+
/** Resolve one HTTP byte-range against a complete cached body. Multiple ranges
|
|
33
|
+
* are intentionally rejected (the CCTV player uses only a single range). */
|
|
34
|
+
export declare function sliceHttpByteRange(body: Buffer, header: string): {
|
|
35
|
+
body: Buffer;
|
|
36
|
+
start: number;
|
|
37
|
+
end: number;
|
|
38
|
+
total: number;
|
|
39
|
+
} | null;
|
|
32
40
|
/**
|
|
33
41
|
* Extract the media-segment URLs from an m3u8 playlist, resolved against the
|
|
34
42
|
* playlist's own URL. Returns [] for master playlists (variant lists) — those
|
|
@@ -128,6 +128,31 @@ export function buildM3u8VariantFallbacks(playlist, baseUrl, maxBandwidth) {
|
|
|
128
128
|
}
|
|
129
129
|
return out;
|
|
130
130
|
}
|
|
131
|
+
/** Resolve one HTTP byte-range against a complete cached body. Multiple ranges
|
|
132
|
+
* are intentionally rejected (the CCTV player uses only a single range). */
|
|
133
|
+
export function sliceHttpByteRange(body, header) {
|
|
134
|
+
const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());
|
|
135
|
+
if (!match || (match[1] === "" && match[2] === "") || body.length === 0)
|
|
136
|
+
return null;
|
|
137
|
+
const total = body.length;
|
|
138
|
+
let start;
|
|
139
|
+
let end;
|
|
140
|
+
if (match[1] === "") {
|
|
141
|
+
const suffix = Number(match[2]);
|
|
142
|
+
if (!Number.isSafeInteger(suffix) || suffix <= 0)
|
|
143
|
+
return null;
|
|
144
|
+
start = Math.max(0, total - suffix);
|
|
145
|
+
end = total - 1;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
start = Number(match[1]);
|
|
149
|
+
end = match[2] === "" ? total - 1 : Number(match[2]);
|
|
150
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= total || end < start)
|
|
151
|
+
return null;
|
|
152
|
+
end = Math.min(end, total - 1);
|
|
153
|
+
}
|
|
154
|
+
return { body: body.subarray(start, end + 1), start, end, total };
|
|
155
|
+
}
|
|
131
156
|
/**
|
|
132
157
|
* Extract the media-segment URLs from an m3u8 playlist, resolved against the
|
|
133
158
|
* playlist's own URL. Returns [] for master playlists (variant lists) — those
|
|
@@ -154,6 +179,8 @@ export class HlsPrefetcher {
|
|
|
154
179
|
#cache = new Map(); // Map preserves insertion order → LRU-ish
|
|
155
180
|
#cacheBytes = 0;
|
|
156
181
|
#inflight = new Map();
|
|
182
|
+
#background = new Map();
|
|
183
|
+
#playlistGeneration = 0;
|
|
157
184
|
#liveBufferPrimed = false;
|
|
158
185
|
#fetcher;
|
|
159
186
|
#maxBytes;
|
|
@@ -241,7 +268,7 @@ export class HlsPrefetcher {
|
|
|
241
268
|
* losing request is aborted immediately so the rescue does not keep splitting
|
|
242
269
|
* bandwidth after a winner is available. Redemption probes are separate,
|
|
243
270
|
* forced-exit requests and are never aborted by this path. */
|
|
244
|
-
#whole(u, extra) {
|
|
271
|
+
#whole(u, extra, outerSignal) {
|
|
245
272
|
const one = (signal, label) => this.#fetcher(u, { ...extra, host: u.host }, label, signal);
|
|
246
273
|
return new Promise((resolve, reject) => {
|
|
247
274
|
let settled = false;
|
|
@@ -250,12 +277,25 @@ export class HlsPrefetcher {
|
|
|
250
277
|
let timer = null;
|
|
251
278
|
const first = new AbortController();
|
|
252
279
|
let hedge = null;
|
|
280
|
+
const cleanup = () => outerSignal?.removeEventListener("abort", onOuterAbort);
|
|
281
|
+
const onOuterAbort = () => {
|
|
282
|
+
if (settled)
|
|
283
|
+
return;
|
|
284
|
+
settled = true;
|
|
285
|
+
if (timer)
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
first.abort();
|
|
288
|
+
hedge?.abort();
|
|
289
|
+
cleanup();
|
|
290
|
+
reject(new Error("aborted"));
|
|
291
|
+
};
|
|
253
292
|
const win = (winner) => (r) => {
|
|
254
293
|
if (settled)
|
|
255
294
|
return;
|
|
256
295
|
settled = true;
|
|
257
296
|
if (timer)
|
|
258
297
|
clearTimeout(timer);
|
|
298
|
+
cleanup();
|
|
259
299
|
(winner === first ? hedge : first)?.abort();
|
|
260
300
|
// Keep the winner alive through resolution; aborting a fully consumed
|
|
261
301
|
// response would only discard its reusable keep-alive socket.
|
|
@@ -273,6 +313,7 @@ export class HlsPrefetcher {
|
|
|
273
313
|
}
|
|
274
314
|
else if (pending === 0) {
|
|
275
315
|
settled = true;
|
|
316
|
+
cleanup();
|
|
276
317
|
reject(e);
|
|
277
318
|
}
|
|
278
319
|
};
|
|
@@ -284,6 +325,11 @@ export class HlsPrefetcher {
|
|
|
284
325
|
pending++;
|
|
285
326
|
one(hedge.signal, "prefetch-hedge").then(win(hedge), lose);
|
|
286
327
|
};
|
|
328
|
+
if (outerSignal?.aborted) {
|
|
329
|
+
onOuterAbort();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
outerSignal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
287
333
|
one(first.signal, "prefetch").then(win(first), lose);
|
|
288
334
|
timer = setTimeout(() => {
|
|
289
335
|
timer = null;
|
|
@@ -297,7 +343,7 @@ export class HlsPrefetcher {
|
|
|
297
343
|
* STRIPE_TRIES exits. Returns null only if every try failed. `exactLen`, when
|
|
298
344
|
* given, rejects a 206 whose body isn't exactly that long (so assembly is
|
|
299
345
|
* byte-correct); the probe passes it undefined to accept whatever 206 arrives. */
|
|
300
|
-
#fetchRange(u, start, end, extra, exactLen, exitHint) {
|
|
346
|
+
#fetchRange(u, start, end, extra, exactLen, exitHint, outerSignal) {
|
|
301
347
|
return new Promise((resolve) => {
|
|
302
348
|
let settled = false;
|
|
303
349
|
let launched = 0;
|
|
@@ -313,11 +359,13 @@ export class HlsPrefetcher {
|
|
|
313
359
|
return;
|
|
314
360
|
settled = true;
|
|
315
361
|
clearHedge();
|
|
362
|
+
outerSignal?.removeEventListener("abort", onOuterAbort);
|
|
316
363
|
for (const ac of controllers)
|
|
317
364
|
if (ac !== winner)
|
|
318
365
|
ac.abort();
|
|
319
366
|
resolve(r);
|
|
320
367
|
};
|
|
368
|
+
const onOuterAbort = () => finish(null);
|
|
321
369
|
const scheduleHedge = () => {
|
|
322
370
|
if (settled || launched >= STRIPE_TRIES || hedgeTimer)
|
|
323
371
|
return;
|
|
@@ -362,6 +410,11 @@ export class HlsPrefetcher {
|
|
|
362
410
|
if (finished >= launched)
|
|
363
411
|
finish(null);
|
|
364
412
|
};
|
|
413
|
+
if (outerSignal?.aborted) {
|
|
414
|
+
finish(null);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
outerSignal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
365
418
|
launch();
|
|
366
419
|
});
|
|
367
420
|
}
|
|
@@ -369,25 +422,29 @@ export class HlsPrefetcher {
|
|
|
369
422
|
* each hedged. Assembles them into the full body with a corrected
|
|
370
423
|
* content-length. Falls back to one whole fetch if the origin ignores Range or
|
|
371
424
|
* any stripe truly can't be fetched (correctness over speed). */
|
|
372
|
-
async #stripeFetch(u, extra) {
|
|
425
|
+
async #stripeFetch(u, extra, signal) {
|
|
426
|
+
if (signal?.aborted)
|
|
427
|
+
throw new Error("aborted");
|
|
373
428
|
const stripeCount = this.#stripeCount();
|
|
374
429
|
// A count of one explicitly disables striping (emergency rollback or an
|
|
375
430
|
// origin/path known not to benefit from parallel range connections).
|
|
376
431
|
if (stripeCount <= 1)
|
|
377
|
-
return this.#whole(u, extra);
|
|
432
|
+
return this.#whole(u, extra, signal);
|
|
378
433
|
// Tiny probe (2 bytes): learn the total size + confirm the origin honors
|
|
379
434
|
// Range, cheaply — a big sequential probe would add a whole round-trip of
|
|
380
435
|
// latency before any stripe starts and starve the live buffer.
|
|
381
|
-
const probe = await this.#fetchRange(u, 0, 1, extra);
|
|
436
|
+
const probe = await this.#fetchRange(u, 0, 1, extra, undefined, undefined, signal);
|
|
437
|
+
if (signal?.aborted)
|
|
438
|
+
throw new Error("aborted");
|
|
382
439
|
if (!probe)
|
|
383
|
-
return this.#whole(u, extra);
|
|
440
|
+
return this.#whole(u, extra, signal);
|
|
384
441
|
if (probe.status === 200)
|
|
385
442
|
return probe; // range ignored → whole body already here
|
|
386
443
|
if (probe.status !== 206)
|
|
387
|
-
return this.#whole(u, extra);
|
|
444
|
+
return this.#whole(u, extra, signal);
|
|
388
445
|
const total = contentRangeTotal(probe.headers["content-range"]);
|
|
389
446
|
if (!total || total <= 0)
|
|
390
|
-
return this.#whole(u, extra);
|
|
447
|
+
return this.#whole(u, extra, signal);
|
|
391
448
|
const headers = { ...probe.headers };
|
|
392
449
|
delete headers["content-range"];
|
|
393
450
|
// Split the WHOLE body into N equal stripes, fetched IN PARALLEL across the
|
|
@@ -402,13 +459,15 @@ export class HlsPrefetcher {
|
|
|
402
459
|
if (start > end)
|
|
403
460
|
return Promise.resolve(Buffer.alloc(0));
|
|
404
461
|
const want = end - start + 1;
|
|
405
|
-
return this.#fetchRange(u, start, end, extra, want, i).then((r) => (r && r.body.length === want ? r.body : null));
|
|
462
|
+
return this.#fetchRange(u, start, end, extra, want, i, signal).then((r) => (r && r.body.length === want ? r.body : null));
|
|
406
463
|
}));
|
|
464
|
+
if (signal?.aborted)
|
|
465
|
+
throw new Error("aborted");
|
|
407
466
|
if (parts.some((p) => p === null))
|
|
408
|
-
return this.#whole(u, extra); // a stripe couldn't be fetched → whole for integrity
|
|
467
|
+
return this.#whole(u, extra, signal); // a stripe couldn't be fetched → whole for integrity
|
|
409
468
|
const body = Buffer.concat(parts);
|
|
410
469
|
if (body.length !== total)
|
|
411
|
-
return this.#whole(u, extra);
|
|
470
|
+
return this.#whole(u, extra, signal);
|
|
412
471
|
headers["content-length"] = String(body.length);
|
|
413
472
|
return { status: 200, headers, body };
|
|
414
473
|
}
|
|
@@ -498,12 +557,35 @@ export class HlsPrefetcher {
|
|
|
498
557
|
const segs = parseM3u8Segments(body, playlistUrl);
|
|
499
558
|
if (segs.length === 0)
|
|
500
559
|
return;
|
|
560
|
+
const generation = ++this.#playlistGeneration;
|
|
501
561
|
// For a live playlist the tail is "upcoming"; for VOD the player walks the
|
|
502
562
|
// list from wherever it is, and refetches of the playlist don't happen —
|
|
503
563
|
// prefetching the head of the list still fills the startup buffer. Either
|
|
504
564
|
// way: take the last `ahead` entries we don't already hold.
|
|
565
|
+
const tail = segs.slice(-this.#ahead);
|
|
566
|
+
const wanted = new Set(tail);
|
|
567
|
+
const cancelled = [];
|
|
568
|
+
for (const [url, controller] of this.#background) {
|
|
569
|
+
if (wanted.has(url))
|
|
570
|
+
continue;
|
|
571
|
+
controller.abort();
|
|
572
|
+
this.#background.delete(url);
|
|
573
|
+
const p = this.#inflight.get(url);
|
|
574
|
+
if (p)
|
|
575
|
+
cancelled.push(p);
|
|
576
|
+
}
|
|
577
|
+
if (cancelled.length > 0) {
|
|
578
|
+
void Promise.allSettled(cancelled).then(() => {
|
|
579
|
+
if (this.#playlistGeneration === generation)
|
|
580
|
+
this.#schedulePrefetch(tail);
|
|
581
|
+
});
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
this.#schedulePrefetch(tail);
|
|
585
|
+
}
|
|
586
|
+
#schedulePrefetch(segs) {
|
|
505
587
|
const conc = Math.max(1, Math.floor(this.#concurrencyFn ? this.#concurrencyFn() : this.#concurrency));
|
|
506
|
-
const want = segs.
|
|
588
|
+
const want = segs.filter((u) => !this.#peek(u) && !this.#inflight.has(u));
|
|
507
589
|
for (const u of want) {
|
|
508
590
|
if (this.#inflight.size >= conc)
|
|
509
591
|
break;
|
|
@@ -511,10 +593,13 @@ export class HlsPrefetcher {
|
|
|
511
593
|
}
|
|
512
594
|
}
|
|
513
595
|
#prefetch(url) {
|
|
514
|
-
const
|
|
596
|
+
const controller = new AbortController();
|
|
597
|
+
this.#background.set(url, controller);
|
|
598
|
+
let p;
|
|
599
|
+
p = (async () => {
|
|
515
600
|
try {
|
|
516
601
|
const u = new URL(url);
|
|
517
|
-
const res = await this.#stripeFetch(u, {});
|
|
602
|
+
const res = await this.#stripeFetch(u, {}, controller.signal);
|
|
518
603
|
if (res.status !== 200 || res.body.length === 0 || res.body.length > this.#maxEntryBytes)
|
|
519
604
|
return null;
|
|
520
605
|
const ct = res.headers["content-type"];
|
|
@@ -530,7 +615,10 @@ export class HlsPrefetcher {
|
|
|
530
615
|
return null;
|
|
531
616
|
}
|
|
532
617
|
finally {
|
|
533
|
-
this.#inflight.
|
|
618
|
+
if (this.#inflight.get(url) === p)
|
|
619
|
+
this.#inflight.delete(url);
|
|
620
|
+
if (this.#background.get(url) === controller)
|
|
621
|
+
this.#background.delete(url);
|
|
534
622
|
}
|
|
535
623
|
})();
|
|
536
624
|
this.#inflight.set(url, p);
|
|
@@ -50,6 +50,7 @@ export interface MultiExitRouterOptions {
|
|
|
50
50
|
}
|
|
51
51
|
export declare const BUILTIN_REGION_DOMAINS: Record<string, string[]>;
|
|
52
52
|
export declare function isUsefulRecoverySample(peakBps: number, averageBps: number, minBps?: number): boolean;
|
|
53
|
+
export declare function redemptionProbeQuietDelay(lastForegroundMs: number, nowMs?: number, quietMs?: number): number;
|
|
53
54
|
interface HlsExitScore {
|
|
54
55
|
speedEwma: number | null;
|
|
55
56
|
flooredAtMs: number | null;
|
|
@@ -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, buildM3u8VariantFallbacks, capM3u8Variants, looksLikePlaylist, looksLikeSegment, parseM3u8Variants, parseM3u8Segments } from "./hls-prefetch.js";
|
|
24
|
+
import { HlsPrefetcher, buildM3u8VariantFallbacks, capM3u8Variants, looksLikePlaylist, looksLikeSegment, parseM3u8Variants, parseM3u8Segments, sliceHttpByteRange } 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";
|
|
@@ -164,15 +164,16 @@ const NEAR_DEAD_EXIT_BPS = 60_000;
|
|
|
164
164
|
// threshold admitted 10.86.15.133 on a short peak even though its full CCTV
|
|
165
165
|
// segments averaged only 0.7-1.2 Mbit/s, causing each half-range to take 4-6s.
|
|
166
166
|
const HLS_DELIVERY_BPS = 200_000;
|
|
167
|
-
// Below this speed even
|
|
167
|
+
// Below this speed even a 0.9 Mbps stream has no useful headroom. If
|
|
168
168
|
// unproven exits remain, keep exploring them instead of locking startup onto
|
|
169
169
|
// the first measured-but-dead path (observed: 0.3 Mbps 15.133 hid the actually
|
|
170
170
|
// usable 134.139 for the remainder of the router process).
|
|
171
171
|
const HLS_STARTUP_EXPLORE_BPS = 100_000;
|
|
172
|
-
//
|
|
173
|
-
//
|
|
172
|
+
// Quality selection belongs to the player/user by default. Operators may set a
|
|
173
|
+
// positive value to impose an explicit site-local ceiling; 0 exposes every
|
|
174
|
+
// origin variant.
|
|
174
175
|
const HLS_MAX_VARIANT_BPS = process.env.AGENTNET_HLS_MAX_BPS === undefined
|
|
175
|
-
?
|
|
176
|
+
? 0
|
|
176
177
|
: Math.max(0, Number(process.env.AGENTNET_HLS_MAX_BPS));
|
|
177
178
|
export function isUsefulRecoverySample(peakBps, averageBps, minBps = HLS_DELIVERY_BPS) {
|
|
178
179
|
return peakBps >= minBps && averageBps >= minBps;
|
|
@@ -238,6 +239,14 @@ const REDEMPTION_MS = 90_000;
|
|
|
238
239
|
// exit is tested with a duplicate background segment while the player's normal
|
|
239
240
|
// fetch remains on proven exits, so recovery discovery cannot stall playback.
|
|
240
241
|
const REDEMPTION_PROBE_GAP_MS = 15_000;
|
|
242
|
+
// A recovery test is deliberately background work: never let it overlap an
|
|
243
|
+
// active live stream. Each foreground segment pushes the timer back; once the
|
|
244
|
+
// player has been quiet for this long, the most recent segment is safe to reuse
|
|
245
|
+
// as a realistic recovery sample.
|
|
246
|
+
const REDEMPTION_PROBE_QUIET_MS = 10_000;
|
|
247
|
+
export function redemptionProbeQuietDelay(lastForegroundMs, nowMs = Date.now(), quietMs = REDEMPTION_PROBE_QUIET_MS) {
|
|
248
|
+
return Math.max(0, quietMs - Math.max(0, nowMs - lastForegroundMs));
|
|
249
|
+
}
|
|
241
250
|
function floorExit(exit) {
|
|
242
251
|
exit.speedEwma = exit.speedEwma === null ? FLOOR_BPS : Math.min(exit.speedEwma, FLOOR_BPS);
|
|
243
252
|
exit.flooredAtMs = Date.now();
|
|
@@ -1136,6 +1145,38 @@ export function startMultiExitRouter(opts) {
|
|
|
1136
1145
|
// Fetch one URL through the region's exit pool, buffered, with failover.
|
|
1137
1146
|
let redemptionProbeInFlight = false;
|
|
1138
1147
|
let lastRedemptionProbeMs = 0;
|
|
1148
|
+
let lastForegroundHlsMs = 0;
|
|
1149
|
+
let redemptionProbeTimer = null;
|
|
1150
|
+
let pendingRedemptionProbe = null;
|
|
1151
|
+
function armRedemptionProbe(delayMs) {
|
|
1152
|
+
if (redemptionProbeTimer)
|
|
1153
|
+
clearTimeout(redemptionProbeTimer);
|
|
1154
|
+
redemptionProbeTimer = setTimeout(() => {
|
|
1155
|
+
redemptionProbeTimer = null;
|
|
1156
|
+
const quietDelay = redemptionProbeQuietDelay(lastForegroundHlsMs);
|
|
1157
|
+
if (quietDelay > 0) {
|
|
1158
|
+
armRedemptionProbe(quietDelay);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
const pending = pendingRedemptionProbe;
|
|
1162
|
+
if (!pending)
|
|
1163
|
+
return;
|
|
1164
|
+
if (redemptionProbeInFlight || Date.now() - lastRedemptionProbeMs < REDEMPTION_PROBE_GAP_MS) {
|
|
1165
|
+
armRedemptionProbe(REDEMPTION_PROBE_GAP_MS);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
maybeStartRedemptionProbe(pending.u, pending.extraHeaders, pending.region);
|
|
1169
|
+
// Keep checking while the player remains idle: an exit may not become
|
|
1170
|
+
// redemption-eligible until its 90-second floor expires.
|
|
1171
|
+
armRedemptionProbe(REDEMPTION_PROBE_GAP_MS);
|
|
1172
|
+
}, Math.max(1, delayMs));
|
|
1173
|
+
redemptionProbeTimer.unref?.();
|
|
1174
|
+
}
|
|
1175
|
+
function deferRedemptionProbeUntilIdle(u, extraHeaders, region) {
|
|
1176
|
+
lastForegroundHlsMs = Date.now();
|
|
1177
|
+
pendingRedemptionProbe = { u: new URL(u.toString()), extraHeaders: { ...extraHeaders }, region };
|
|
1178
|
+
armRedemptionProbe(REDEMPTION_PROBE_QUIET_MS);
|
|
1179
|
+
}
|
|
1139
1180
|
function maybeStartRedemptionProbe(u, extraHeaders, region) {
|
|
1140
1181
|
const now = Date.now();
|
|
1141
1182
|
if (redemptionProbeInFlight || now - lastRedemptionProbeMs < REDEMPTION_PROBE_GAP_MS)
|
|
@@ -1204,7 +1245,7 @@ export function startMultiExitRouter(opts) {
|
|
|
1204
1245
|
return;
|
|
1205
1246
|
}
|
|
1206
1247
|
if (!forcedExit && label === "prefetch")
|
|
1207
|
-
|
|
1248
|
+
deferRedemptionProbeUntilIdle(u, extraHeaders, region);
|
|
1208
1249
|
const base0 = forcedExit ? [forcedExit] : pickOrder(region);
|
|
1209
1250
|
if (base0.length === 0) {
|
|
1210
1251
|
reject(new Error("no healthy exit"));
|
|
@@ -1376,17 +1417,14 @@ export function startMultiExitRouter(opts) {
|
|
|
1376
1417
|
tryIdx(0);
|
|
1377
1418
|
});
|
|
1378
1419
|
}
|
|
1379
|
-
// Striping
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1385
|
-
//
|
|
1386
|
-
|
|
1387
|
-
// split fetching the same segment twice. concurrency:2 keeps at most 2
|
|
1388
|
-
// segments (≈6 stripes over ~3 exits) in flight so the pool isn't overwhelmed.
|
|
1389
|
-
const AGG = process.env.AGENTNET_HLS_STRIPES ? Math.max(1, Number(process.env.AGENTNET_HLS_STRIPES)) : 3;
|
|
1420
|
+
// Striping can aggregate genuinely independent exits, but it is harmful when
|
|
1421
|
+
// every range converges on one useful shared exit. Three stripes, a hedge per
|
|
1422
|
+
// stripe, and retries produced 6-11 simultaneous requests to 10.86.134.139;
|
|
1423
|
+
// its measured throughput collapsed from ~2 Mbps to ~0.3 Mbps and every
|
|
1424
|
+
// client sharing that exit stalled. Default to one whole-segment fetch (with
|
|
1425
|
+
// the existing single late hedge). Operators may explicitly opt into more
|
|
1426
|
+
// stripes after proving their exits have independent capacity.
|
|
1427
|
+
const AGG = process.env.AGENTNET_HLS_STRIPES ? Math.max(1, Number(process.env.AGENTNET_HLS_STRIPES)) : 1;
|
|
1390
1428
|
// How many china exits are ACTUALLY delivering right now — healthy (or with
|
|
1391
1429
|
// live traffic), not tripped, and not speed-penalized (an exit that returned
|
|
1392
1430
|
// empty/0-byte gets its score floored, so it drops out here until it serves
|
|
@@ -1408,14 +1446,11 @@ export function startMultiExitRouter(opts) {
|
|
|
1408
1446
|
return exits.some((e) => e.region === fallbackRegion && e.trippedUntil <= now &&
|
|
1409
1447
|
e.flooredAtMs === null && e.speedEwma !== null && e.speedEwma >= HLS_STARTUP_EXPLORE_BPS);
|
|
1410
1448
|
};
|
|
1411
|
-
// Stripe count is a connection count, not a distinct-exit count.
|
|
1412
|
-
//
|
|
1413
|
-
//
|
|
1414
|
-
//
|
|
1415
|
-
//
|
|
1416
|
-
// exit has delivered a real fast sample; otherwise the unproven startup pool
|
|
1417
|
-
// would scatter half-ranges onto every slow exit and hold assembly hostage.
|
|
1418
|
-
// Set AGENTNET_HLS_STRIPES=1 for an emergency rollback.
|
|
1449
|
+
// Stripe count is a connection count, not a distinct-exit count. During
|
|
1450
|
+
// startup, keep whole-segment mode until at least one exit has delivered a
|
|
1451
|
+
// real fast sample; otherwise the unproven startup pool would scatter ranges
|
|
1452
|
+
// onto every slow exit and hold assembly hostage. AGENTNET_HLS_STRIPES=N is
|
|
1453
|
+
// an explicit opt-in for pools where N independent exits are proven useful.
|
|
1419
1454
|
const usableChinaExits = () => AGG <= 1 || !hasMeasuredChinaExit() ? 1 : AGG;
|
|
1420
1455
|
const hlsConcurrency = () => hasMeasuredChinaExit() ? deliveringChinaExits() : 1;
|
|
1421
1456
|
// Prefetch parallelism = the number of exits ACTUALLY delivering right now
|
|
@@ -1483,23 +1518,36 @@ export function startMultiExitRouter(opts) {
|
|
|
1483
1518
|
res.end();
|
|
1484
1519
|
return;
|
|
1485
1520
|
}
|
|
1486
|
-
const
|
|
1521
|
+
const isPlaylist = looksLikePlaylist(u.pathname || "");
|
|
1522
|
+
const isSegment = looksLikeSegment(u.pathname || "");
|
|
1523
|
+
const requestedRange = typeof headers["range"] === "string" ? headers["range"] : null;
|
|
1524
|
+
const cacheable = method === "GET";
|
|
1487
1525
|
if (cacheable) {
|
|
1488
1526
|
const hit = hls.lookup(fullUrl);
|
|
1489
1527
|
if (hit) {
|
|
1528
|
+
const ranged = requestedRange && isSegment ? sliceHttpByteRange(hit.body, requestedRange) : null;
|
|
1529
|
+
if (requestedRange && isSegment && !ranged) {
|
|
1530
|
+
const hh = { "content-range": `bytes */${hit.body.length}`, "content-length": 0 };
|
|
1531
|
+
addCors(hh);
|
|
1532
|
+
res.writeHead(416, hh);
|
|
1533
|
+
res.end();
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
const body = ranged?.body ?? hit.body;
|
|
1490
1537
|
const hh = {
|
|
1491
1538
|
"content-type": hit.contentType ?? "application/octet-stream",
|
|
1492
|
-
"content-length":
|
|
1539
|
+
"content-length": body.length,
|
|
1493
1540
|
"x-agentnet-cache": "hit",
|
|
1541
|
+
"accept-ranges": "bytes",
|
|
1494
1542
|
};
|
|
1543
|
+
if (ranged)
|
|
1544
|
+
hh["content-range"] = `bytes ${ranged.start}-${ranged.end}/${ranged.total}`;
|
|
1495
1545
|
addCors(hh);
|
|
1496
|
-
res.writeHead(200, hh);
|
|
1497
|
-
res.end(
|
|
1546
|
+
res.writeHead(ranged ? 206 : 200, hh);
|
|
1547
|
+
res.end(body);
|
|
1498
1548
|
bumpHlsStats();
|
|
1499
1549
|
return;
|
|
1500
1550
|
}
|
|
1501
|
-
const isPlaylist = looksLikePlaylist(u.pathname || "");
|
|
1502
|
-
const isSegment = looksLikeSegment(u.pathname || "");
|
|
1503
1551
|
if (isPlaylist || isSegment) {
|
|
1504
1552
|
const fwd = {};
|
|
1505
1553
|
for (const [k, v] of Object.entries(headers)) {
|
|
@@ -1545,17 +1593,33 @@ export function startMultiExitRouter(opts) {
|
|
|
1545
1593
|
log(` playlist ${host} is MEDIA — ${segs.length} segments, prefetching ahead`);
|
|
1546
1594
|
}
|
|
1547
1595
|
}
|
|
1548
|
-
|
|
1596
|
+
let responseStatus = r.status;
|
|
1597
|
+
let responseBody = r.body;
|
|
1598
|
+
const outHeaders = { ...r.headers };
|
|
1599
|
+
if (requestedRange && isSegment && r.status === 200) {
|
|
1600
|
+
const ranged = sliceHttpByteRange(r.body, requestedRange);
|
|
1601
|
+
if (!ranged) {
|
|
1602
|
+
responseStatus = 416;
|
|
1603
|
+
responseBody = Buffer.alloc(0);
|
|
1604
|
+
outHeaders["content-range"] = `bytes */${r.body.length}`;
|
|
1605
|
+
}
|
|
1606
|
+
else {
|
|
1607
|
+
responseStatus = 206;
|
|
1608
|
+
responseBody = ranged.body;
|
|
1609
|
+
outHeaders["content-range"] = `bytes ${ranged.start}-${ranged.end}/${ranged.total}`;
|
|
1610
|
+
outHeaders["accept-ranges"] = "bytes";
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
log(` MITM ${method} ${host}${(path || "/").slice(0, 80)} → ${responseStatus} (${responseBody.length}B)`);
|
|
1549
1614
|
if (r.status >= 400) {
|
|
1550
1615
|
const snip = r.body.slice(0, 300).toString("utf8").replace(/\s+/g, " ");
|
|
1551
1616
|
log(` body: ${snip}`);
|
|
1552
1617
|
}
|
|
1553
|
-
const outHeaders = { ...r.headers };
|
|
1554
1618
|
delete outHeaders["transfer-encoding"];
|
|
1555
|
-
outHeaders["content-length"] =
|
|
1619
|
+
outHeaders["content-length"] = responseBody.length;
|
|
1556
1620
|
addCors(outHeaders); // cache/dedup/striped paths drop the CDN's CORS header — always re-add
|
|
1557
|
-
res.writeHead(
|
|
1558
|
-
res.end(
|
|
1621
|
+
res.writeHead(responseStatus, outHeaders);
|
|
1622
|
+
res.end(responseBody);
|
|
1559
1623
|
if (r.status === 200) {
|
|
1560
1624
|
const ct = r.headers["content-type"];
|
|
1561
1625
|
if (isPlaylist) {
|
|
@@ -1750,6 +1814,13 @@ export function startMultiExitRouter(opts) {
|
|
|
1750
1814
|
server.on("close", () => clearInterval(reporter));
|
|
1751
1815
|
}
|
|
1752
1816
|
return {
|
|
1753
|
-
stop: () => {
|
|
1817
|
+
stop: () => {
|
|
1818
|
+
stopped = true;
|
|
1819
|
+
if (redemptionProbeTimer)
|
|
1820
|
+
clearTimeout(redemptionProbeTimer);
|
|
1821
|
+
redemptionProbeTimer = null;
|
|
1822
|
+
pendingRedemptionProbe = null;
|
|
1823
|
+
server.close();
|
|
1824
|
+
},
|
|
1754
1825
|
};
|
|
1755
1826
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.198",
|
|
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",
|