@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.
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.slice(-this.#ahead).filter((u) => !this.#peek(u) && !this.#inflight.has(u));
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 p = (async () => {
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.delete(url);
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
- maybeStartRedemptionProbe(u, extraHeaders, region);
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: () => { stopped = true; server.close(); },
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.197",
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",