@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.
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.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));
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 p = (async () => {
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.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);
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 the capped 0.9 Mbps stream has no useful headroom. If
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
- // Stability-first default for constrained exit pools. Set 0 to expose every
173
- // origin variant, or 1800000 to restore CCTV 720P when another fast exit joins.
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
- ? 900_000
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
- maybeStartRedemptionProbe(u, extraHeaders, region);
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 ON — PROVEN to aggregate: a 600KB range that takes 8.5s on one
1380
- // China exit (0.57 Mbps) lands in 3.9s when split 3 ways across exits
1381
- // (1.24 Mbps, 2.2×). The GFW throttles PER-CONNECTION, so parallel ranges on
1382
- // separate exit tunnels sum. A tiny 2-byte probe learns the size, then the
1383
- // whole body is pulled as parallel byte-ranges; each range is hedged
1384
- // (timeout retry another exit) and the empty-body trip + min-throughput
1385
- // deadline in fetchViaRegionBuffered keep a dead/trickling exit from stalling
1386
- // it. Player requests JOIN an in-flight prefetch (dedup) so bandwidth isn't
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. Keep three
1412
- // parallel ranges even when only one exit is qualified: two ranges still
1413
- // delivered CCTV's 1800 stream in 3.6-4.9s, which is marginal for a 4s live
1414
- // segment, while one connection repeatedly showed 6-10s tails. During
1415
- // startup, keep whole-segment mode until at least one
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 cacheable = method === "GET" && !headers["range"];
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": hit.body.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(hit.body);
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
- log(` MITM ${method} ${host}${(path || "/").slice(0, 80)} → ${r.status} (${r.body.length}B)`);
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"] = r.body.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(r.status, outHeaders);
1558
- res.end(r.body);
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: () => { 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
+ },
1754
1825
  };
1755
1826
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.196",
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",