@decentnetwork/lan 0.1.197 → 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.js +78 -15
- package/dist/proxy/multi-exit-router.d.ts +1 -0
- package/dist/proxy/multi-exit-router.js +49 -2
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -179,6 +179,8 @@ export class HlsPrefetcher {
|
|
|
179
179
|
#cache = new Map(); // Map preserves insertion order → LRU-ish
|
|
180
180
|
#cacheBytes = 0;
|
|
181
181
|
#inflight = new Map();
|
|
182
|
+
#background = new Map();
|
|
183
|
+
#playlistGeneration = 0;
|
|
182
184
|
#liveBufferPrimed = false;
|
|
183
185
|
#fetcher;
|
|
184
186
|
#maxBytes;
|
|
@@ -266,7 +268,7 @@ export class HlsPrefetcher {
|
|
|
266
268
|
* losing request is aborted immediately so the rescue does not keep splitting
|
|
267
269
|
* bandwidth after a winner is available. Redemption probes are separate,
|
|
268
270
|
* forced-exit requests and are never aborted by this path. */
|
|
269
|
-
#whole(u, extra) {
|
|
271
|
+
#whole(u, extra, outerSignal) {
|
|
270
272
|
const one = (signal, label) => this.#fetcher(u, { ...extra, host: u.host }, label, signal);
|
|
271
273
|
return new Promise((resolve, reject) => {
|
|
272
274
|
let settled = false;
|
|
@@ -275,12 +277,25 @@ export class HlsPrefetcher {
|
|
|
275
277
|
let timer = null;
|
|
276
278
|
const first = new AbortController();
|
|
277
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
|
+
};
|
|
278
292
|
const win = (winner) => (r) => {
|
|
279
293
|
if (settled)
|
|
280
294
|
return;
|
|
281
295
|
settled = true;
|
|
282
296
|
if (timer)
|
|
283
297
|
clearTimeout(timer);
|
|
298
|
+
cleanup();
|
|
284
299
|
(winner === first ? hedge : first)?.abort();
|
|
285
300
|
// Keep the winner alive through resolution; aborting a fully consumed
|
|
286
301
|
// response would only discard its reusable keep-alive socket.
|
|
@@ -298,6 +313,7 @@ export class HlsPrefetcher {
|
|
|
298
313
|
}
|
|
299
314
|
else if (pending === 0) {
|
|
300
315
|
settled = true;
|
|
316
|
+
cleanup();
|
|
301
317
|
reject(e);
|
|
302
318
|
}
|
|
303
319
|
};
|
|
@@ -309,6 +325,11 @@ export class HlsPrefetcher {
|
|
|
309
325
|
pending++;
|
|
310
326
|
one(hedge.signal, "prefetch-hedge").then(win(hedge), lose);
|
|
311
327
|
};
|
|
328
|
+
if (outerSignal?.aborted) {
|
|
329
|
+
onOuterAbort();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
outerSignal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
312
333
|
one(first.signal, "prefetch").then(win(first), lose);
|
|
313
334
|
timer = setTimeout(() => {
|
|
314
335
|
timer = null;
|
|
@@ -322,7 +343,7 @@ export class HlsPrefetcher {
|
|
|
322
343
|
* STRIPE_TRIES exits. Returns null only if every try failed. `exactLen`, when
|
|
323
344
|
* given, rejects a 206 whose body isn't exactly that long (so assembly is
|
|
324
345
|
* byte-correct); the probe passes it undefined to accept whatever 206 arrives. */
|
|
325
|
-
#fetchRange(u, start, end, extra, exactLen, exitHint) {
|
|
346
|
+
#fetchRange(u, start, end, extra, exactLen, exitHint, outerSignal) {
|
|
326
347
|
return new Promise((resolve) => {
|
|
327
348
|
let settled = false;
|
|
328
349
|
let launched = 0;
|
|
@@ -338,11 +359,13 @@ export class HlsPrefetcher {
|
|
|
338
359
|
return;
|
|
339
360
|
settled = true;
|
|
340
361
|
clearHedge();
|
|
362
|
+
outerSignal?.removeEventListener("abort", onOuterAbort);
|
|
341
363
|
for (const ac of controllers)
|
|
342
364
|
if (ac !== winner)
|
|
343
365
|
ac.abort();
|
|
344
366
|
resolve(r);
|
|
345
367
|
};
|
|
368
|
+
const onOuterAbort = () => finish(null);
|
|
346
369
|
const scheduleHedge = () => {
|
|
347
370
|
if (settled || launched >= STRIPE_TRIES || hedgeTimer)
|
|
348
371
|
return;
|
|
@@ -387,6 +410,11 @@ export class HlsPrefetcher {
|
|
|
387
410
|
if (finished >= launched)
|
|
388
411
|
finish(null);
|
|
389
412
|
};
|
|
413
|
+
if (outerSignal?.aborted) {
|
|
414
|
+
finish(null);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
outerSignal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
390
418
|
launch();
|
|
391
419
|
});
|
|
392
420
|
}
|
|
@@ -394,25 +422,29 @@ export class HlsPrefetcher {
|
|
|
394
422
|
* each hedged. Assembles them into the full body with a corrected
|
|
395
423
|
* content-length. Falls back to one whole fetch if the origin ignores Range or
|
|
396
424
|
* any stripe truly can't be fetched (correctness over speed). */
|
|
397
|
-
async #stripeFetch(u, extra) {
|
|
425
|
+
async #stripeFetch(u, extra, signal) {
|
|
426
|
+
if (signal?.aborted)
|
|
427
|
+
throw new Error("aborted");
|
|
398
428
|
const stripeCount = this.#stripeCount();
|
|
399
429
|
// A count of one explicitly disables striping (emergency rollback or an
|
|
400
430
|
// origin/path known not to benefit from parallel range connections).
|
|
401
431
|
if (stripeCount <= 1)
|
|
402
|
-
return this.#whole(u, extra);
|
|
432
|
+
return this.#whole(u, extra, signal);
|
|
403
433
|
// Tiny probe (2 bytes): learn the total size + confirm the origin honors
|
|
404
434
|
// Range, cheaply — a big sequential probe would add a whole round-trip of
|
|
405
435
|
// latency before any stripe starts and starve the live buffer.
|
|
406
|
-
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");
|
|
407
439
|
if (!probe)
|
|
408
|
-
return this.#whole(u, extra);
|
|
440
|
+
return this.#whole(u, extra, signal);
|
|
409
441
|
if (probe.status === 200)
|
|
410
442
|
return probe; // range ignored → whole body already here
|
|
411
443
|
if (probe.status !== 206)
|
|
412
|
-
return this.#whole(u, extra);
|
|
444
|
+
return this.#whole(u, extra, signal);
|
|
413
445
|
const total = contentRangeTotal(probe.headers["content-range"]);
|
|
414
446
|
if (!total || total <= 0)
|
|
415
|
-
return this.#whole(u, extra);
|
|
447
|
+
return this.#whole(u, extra, signal);
|
|
416
448
|
const headers = { ...probe.headers };
|
|
417
449
|
delete headers["content-range"];
|
|
418
450
|
// Split the WHOLE body into N equal stripes, fetched IN PARALLEL across the
|
|
@@ -427,13 +459,15 @@ export class HlsPrefetcher {
|
|
|
427
459
|
if (start > end)
|
|
428
460
|
return Promise.resolve(Buffer.alloc(0));
|
|
429
461
|
const want = end - start + 1;
|
|
430
|
-
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));
|
|
431
463
|
}));
|
|
464
|
+
if (signal?.aborted)
|
|
465
|
+
throw new Error("aborted");
|
|
432
466
|
if (parts.some((p) => p === null))
|
|
433
|
-
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
|
|
434
468
|
const body = Buffer.concat(parts);
|
|
435
469
|
if (body.length !== total)
|
|
436
|
-
return this.#whole(u, extra);
|
|
470
|
+
return this.#whole(u, extra, signal);
|
|
437
471
|
headers["content-length"] = String(body.length);
|
|
438
472
|
return { status: 200, headers, body };
|
|
439
473
|
}
|
|
@@ -523,12 +557,35 @@ export class HlsPrefetcher {
|
|
|
523
557
|
const segs = parseM3u8Segments(body, playlistUrl);
|
|
524
558
|
if (segs.length === 0)
|
|
525
559
|
return;
|
|
560
|
+
const generation = ++this.#playlistGeneration;
|
|
526
561
|
// For a live playlist the tail is "upcoming"; for VOD the player walks the
|
|
527
562
|
// list from wherever it is, and refetches of the playlist don't happen —
|
|
528
563
|
// prefetching the head of the list still fills the startup buffer. Either
|
|
529
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) {
|
|
530
587
|
const conc = Math.max(1, Math.floor(this.#concurrencyFn ? this.#concurrencyFn() : this.#concurrency));
|
|
531
|
-
const want = segs.
|
|
588
|
+
const want = segs.filter((u) => !this.#peek(u) && !this.#inflight.has(u));
|
|
532
589
|
for (const u of want) {
|
|
533
590
|
if (this.#inflight.size >= conc)
|
|
534
591
|
break;
|
|
@@ -536,10 +593,13 @@ export class HlsPrefetcher {
|
|
|
536
593
|
}
|
|
537
594
|
}
|
|
538
595
|
#prefetch(url) {
|
|
539
|
-
const
|
|
596
|
+
const controller = new AbortController();
|
|
597
|
+
this.#background.set(url, controller);
|
|
598
|
+
let p;
|
|
599
|
+
p = (async () => {
|
|
540
600
|
try {
|
|
541
601
|
const u = new URL(url);
|
|
542
|
-
const res = await this.#stripeFetch(u, {});
|
|
602
|
+
const res = await this.#stripeFetch(u, {}, controller.signal);
|
|
543
603
|
if (res.status !== 200 || res.body.length === 0 || res.body.length > this.#maxEntryBytes)
|
|
544
604
|
return null;
|
|
545
605
|
const ct = res.headers["content-type"];
|
|
@@ -555,7 +615,10 @@ export class HlsPrefetcher {
|
|
|
555
615
|
return null;
|
|
556
616
|
}
|
|
557
617
|
finally {
|
|
558
|
-
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);
|
|
559
622
|
}
|
|
560
623
|
})();
|
|
561
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;
|
|
@@ -239,6 +239,14 @@ const REDEMPTION_MS = 90_000;
|
|
|
239
239
|
// exit is tested with a duplicate background segment while the player's normal
|
|
240
240
|
// fetch remains on proven exits, so recovery discovery cannot stall playback.
|
|
241
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
|
+
}
|
|
242
250
|
function floorExit(exit) {
|
|
243
251
|
exit.speedEwma = exit.speedEwma === null ? FLOOR_BPS : Math.min(exit.speedEwma, FLOOR_BPS);
|
|
244
252
|
exit.flooredAtMs = Date.now();
|
|
@@ -1137,6 +1145,38 @@ export function startMultiExitRouter(opts) {
|
|
|
1137
1145
|
// Fetch one URL through the region's exit pool, buffered, with failover.
|
|
1138
1146
|
let redemptionProbeInFlight = false;
|
|
1139
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
|
+
}
|
|
1140
1180
|
function maybeStartRedemptionProbe(u, extraHeaders, region) {
|
|
1141
1181
|
const now = Date.now();
|
|
1142
1182
|
if (redemptionProbeInFlight || now - lastRedemptionProbeMs < REDEMPTION_PROBE_GAP_MS)
|
|
@@ -1205,7 +1245,7 @@ export function startMultiExitRouter(opts) {
|
|
|
1205
1245
|
return;
|
|
1206
1246
|
}
|
|
1207
1247
|
if (!forcedExit && label === "prefetch")
|
|
1208
|
-
|
|
1248
|
+
deferRedemptionProbeUntilIdle(u, extraHeaders, region);
|
|
1209
1249
|
const base0 = forcedExit ? [forcedExit] : pickOrder(region);
|
|
1210
1250
|
if (base0.length === 0) {
|
|
1211
1251
|
reject(new Error("no healthy exit"));
|
|
@@ -1774,6 +1814,13 @@ export function startMultiExitRouter(opts) {
|
|
|
1774
1814
|
server.on("close", () => clearInterval(reporter));
|
|
1775
1815
|
}
|
|
1776
1816
|
return {
|
|
1777
|
-
stop: () => {
|
|
1817
|
+
stop: () => {
|
|
1818
|
+
stopped = true;
|
|
1819
|
+
if (redemptionProbeTimer)
|
|
1820
|
+
clearTimeout(redemptionProbeTimer);
|
|
1821
|
+
redemptionProbeTimer = null;
|
|
1822
|
+
pendingRedemptionProbe = null;
|
|
1823
|
+
server.close();
|
|
1824
|
+
},
|
|
1778
1825
|
};
|
|
1779
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",
|