@decentnetwork/lan 0.1.183 → 0.1.185

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
@@ -41,6 +41,13 @@ export interface HlsPrefetcherOptions {
41
41
  concurrency?: number;
42
42
  /** Skip caching bodies larger than this (default 32 MB). */
43
43
  maxEntryBytes?: number;
44
+ /** Split each segment into this many parallel byte-range stripes across the
45
+ * exit pool (default 6). 1 disables striping. Lets ONE segment ride the SUM
46
+ * of the exits' bandwidth instead of a single throttled connection. */
47
+ stripes?: number;
48
+ /** Stripe chunk size — also the first probe chunk (default 192 KB). Segments
49
+ * smaller than this aren't striped. */
50
+ stripeChunkBytes?: number;
44
51
  }
45
52
  export declare class HlsPrefetcher {
46
53
  #private;
@@ -17,6 +17,12 @@
17
17
  // master playlist (#EXT-X-STREAM-INF) lists variants, not segments.
18
18
  //
19
19
  const SEGMENT_EXTS = [".ts", ".m4s", ".m4a", ".m4v", ".aac", ".mp3", ".mp4", ".vtt"];
20
+ /** Parse the total size out of a `Content-Range: bytes 0-N/TOTAL` header. */
21
+ function contentRangeTotal(cr) {
22
+ const s = Array.isArray(cr) ? cr[0] : cr;
23
+ const m = s ? /\/(\d+)\s*$/.exec(s) : null;
24
+ return m ? Number(m[1]) : 0;
25
+ }
20
26
  export function looksLikePlaylist(pathname) {
21
27
  const p = pathname.toLowerCase();
22
28
  return p.endsWith(".m3u8") || p.endsWith(".m3u");
@@ -91,6 +97,8 @@ export class HlsPrefetcher {
91
97
  #ahead;
92
98
  #concurrency;
93
99
  #maxEntryBytes;
100
+ #stripes;
101
+ #stripeChunkBytes;
94
102
  #hits = 0;
95
103
  #misses = 0;
96
104
  constructor(fetcher, opts = {}) {
@@ -100,6 +108,69 @@ export class HlsPrefetcher {
100
108
  this.#ahead = opts.prefetchAhead ?? 4;
101
109
  this.#concurrency = opts.concurrency ?? 4;
102
110
  this.#maxEntryBytes = opts.maxEntryBytes ?? 32 * 1024 * 1024;
111
+ this.#stripes = Math.max(1, opts.stripes ?? 6);
112
+ this.#stripeChunkBytes = opts.stripeChunkBytes ?? 192 * 1024;
113
+ }
114
+ /** Fetch `u` as PARALLEL byte-range stripes. Each range is a separate
115
+ * `#fetcher` call, which the router load-balances onto a DIFFERENT exit — so
116
+ * one segment downloads at the SUM of the pool's bandwidth, beating the GFW
117
+ * per-connection throttle (the reason a single stream stalls even with many
118
+ * exits). Falls back to one whole-body fetch if the origin ignores Range or
119
+ * any stripe fails (correctness over speed). */
120
+ async #stripeFetch(u) {
121
+ const whole = () => this.#fetcher(u, { host: u.host }, "prefetch");
122
+ if (this.#stripes <= 1)
123
+ return whole();
124
+ // Tiny probe to learn total size + confirm Range support (hedged so a dead
125
+ // exit doesn't stall the whole segment before parallelism starts).
126
+ const probe = await this.#fetchRange(u, "bytes=0-1", 2000, 3);
127
+ if (!probe || probe.status !== 206)
128
+ return whole();
129
+ const total = contentRangeTotal(probe.headers["content-range"]);
130
+ const outHeaders = { ...probe.headers };
131
+ delete outHeaders["content-range"];
132
+ if (!total)
133
+ return whole();
134
+ if (total <= this.#stripeChunkBytes) {
135
+ // Small segment — one range fetch (hedged) is enough.
136
+ const one = await this.#fetchRange(u, `bytes=0-${total - 1}`, 6000, 3);
137
+ return one && one.body.length === total ? { status: 200, headers: outHeaders, body: one.body } : whole();
138
+ }
139
+ const n = Math.max(2, Math.min(this.#stripes, Math.ceil(total / this.#stripeChunkBytes)));
140
+ const chunk = Math.ceil(total / n);
141
+ // Timeout scales with chunk size: allow ~0.3 Mbps before we give up on an
142
+ // exit and retry the SAME range on another (kills the dead-exit straggler
143
+ // that otherwise holds the whole segment hostage).
144
+ const rangeTimeout = Math.max(3000, Math.round((chunk * 8) / 300)); // ms at 0.3 Mbps
145
+ const parts = await Promise.all(Array.from({ length: n }, (_, i) => {
146
+ const start = i * chunk;
147
+ const end = Math.min(start + chunk - 1, total - 1);
148
+ if (start > end)
149
+ return Promise.resolve(Buffer.alloc(0));
150
+ return this.#fetchRange(u, `bytes=${start}-${end}`, rangeTimeout, 3)
151
+ .then((r) => (r && r.body.length === end - start + 1 ? r.body : null));
152
+ }));
153
+ if (parts.some((p) => p === null))
154
+ return whole(); // a range never landed → refetch whole for integrity
155
+ const body = Buffer.concat(parts);
156
+ if (body.length !== total)
157
+ return whole();
158
+ return { status: 200, headers: outHeaders, body };
159
+ }
160
+ /** Fetch one byte-range with a slow-transfer TIMEOUT + retry on another exit.
161
+ * A stripe stuck on a dead exit (0.0 Mbps) times out and is re-issued (the
162
+ * router rotates to a different exit), so one bad exit can't stall a segment. */
163
+ async #fetchRange(u, range, timeoutMs, tries) {
164
+ for (let t = 0; t < tries; t++) {
165
+ const got = await Promise.race([
166
+ this.#fetcher(u, { host: u.host, range }, "stripe").catch(() => null),
167
+ new Promise((res) => setTimeout(() => res("timeout"), timeoutMs)),
168
+ ]);
169
+ if (got && got !== "timeout" && (got.status === 206 || got.status === 200))
170
+ return got;
171
+ // timed out (slow exit) or errored → loop; the next fetch rotates exits.
172
+ }
173
+ return null;
103
174
  }
104
175
  get stats() {
105
176
  return { hits: this.#hits, misses: this.#misses, entries: this.#cache.size, bytes: this.#cacheBytes };
@@ -158,7 +229,7 @@ export class HlsPrefetcher {
158
229
  const p = (async () => {
159
230
  try {
160
231
  const u = new URL(url);
161
- const res = await this.#fetcher(u, { host: u.host }, "prefetch");
232
+ const res = await this.#stripeFetch(u);
162
233
  if (res.status !== 200 || res.body.length === 0 || res.body.length > this.#maxEntryBytes)
163
234
  return null;
164
235
  const ct = res.headers["content-type"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.183",
3
+ "version": "0.1.185",
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",