@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.#
|
|
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.
|
|
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",
|