@decentnetwork/lan 0.1.190 → 0.1.192
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
|
|
@@ -49,6 +49,19 @@ export interface MultiExitRouterOptions {
|
|
|
49
49
|
log?: (msg: string) => void;
|
|
50
50
|
}
|
|
51
51
|
export declare const BUILTIN_REGION_DOMAINS: Record<string, string[]>;
|
|
52
|
+
export declare function isUsefulRecoverySample(peakBps: number, averageBps: number, minBps?: number): boolean;
|
|
53
|
+
interface HlsExitScore {
|
|
54
|
+
speedEwma: number | null;
|
|
55
|
+
flooredAtMs: number | null;
|
|
56
|
+
}
|
|
57
|
+
/** Choose exits safe for a foreground HLS fetch. Once any proven-fast exit is
|
|
58
|
+
* available, unproven/redeeming exits cannot borrow its score and displace it.
|
|
59
|
+
* With no proven exit, unproven exits remain the startup/failover path; if every
|
|
60
|
+
* exit is degraded, keep the original pool as a last resort. Exported for the
|
|
61
|
+
* policy regression tests. */
|
|
62
|
+
export declare function selectHlsPrimaryPool<T extends HlsExitScore>(pool: T[]): T[];
|
|
63
|
+
/** Dynamic prefetch concurrency follows only the foreground-safe pool. */
|
|
64
|
+
export declare function countHlsDeliveryExits<T extends HlsExitScore>(pool: T[]): number;
|
|
52
65
|
/**
|
|
53
66
|
* Start the multi-exit router. Returns a stop() that closes the listener.
|
|
54
67
|
* Runs until stopped (the CLI command keeps the process alive).
|
|
@@ -56,3 +69,4 @@ export declare const BUILTIN_REGION_DOMAINS: Record<string, string[]>;
|
|
|
56
69
|
export declare function startMultiExitRouter(opts: MultiExitRouterOptions): {
|
|
57
70
|
stop: () => void;
|
|
58
71
|
};
|
|
72
|
+
export {};
|
|
@@ -149,6 +149,16 @@ const NEVER_LEARN = /(^|\.)(google|gstatic|googleapis|googleusercontent|ggpht|gv
|
|
|
149
149
|
// (≈0.5 Mbit/s) keeps every usable exit in and sits safely ABOVE the 40 KB/s
|
|
150
150
|
// deprioritize floor so a floored exit still drops out.
|
|
151
151
|
const NEAR_DEAD_EXIT_BPS = 60_000;
|
|
152
|
+
// Foreground HLS needs a materially stronger exit than ordinary browsing.
|
|
153
|
+
// CCTV's 720p variant is 1.8 Mbit/s; an exit below ~1.2 Mbit/s cannot deliver
|
|
154
|
+
// one four-second segment quickly enough to be useful even with a small live
|
|
155
|
+
// buffer. Keep such exits available for last-resort failover, but do not count
|
|
156
|
+
// them as HLS delivery capacity or let a slow redemption probe re-enable them.
|
|
157
|
+
// This is bytes/sec (150 KB/s = 1.2 Mbit/s).
|
|
158
|
+
const HLS_DELIVERY_BPS = 150_000;
|
|
159
|
+
export function isUsefulRecoverySample(peakBps, averageBps, minBps = HLS_DELIVERY_BPS) {
|
|
160
|
+
return peakBps >= minBps && averageBps >= minBps;
|
|
161
|
+
}
|
|
152
162
|
// A buffered segment fetch is given a deadline sized to deliver its body at
|
|
153
163
|
// roughly this bitrate; an exit that can't keep up is abandoned and the next is
|
|
154
164
|
// tried. Live HLS needs each ~4s segment in a few seconds, so this is set near
|
|
@@ -206,6 +216,10 @@ class RateMeter {
|
|
|
206
216
|
* segments — but KEEP it in the pool (last-resort failover, small requests). */
|
|
207
217
|
const FLOOR_BPS = 40_000;
|
|
208
218
|
const REDEMPTION_MS = 90_000;
|
|
219
|
+
// Redemption probes are intentionally serialized and spaced out. A degraded
|
|
220
|
+
// exit is tested with a duplicate background segment while the player's normal
|
|
221
|
+
// fetch remains on proven exits, so recovery discovery cannot stall playback.
|
|
222
|
+
const REDEMPTION_PROBE_GAP_MS = 15_000;
|
|
209
223
|
function floorExit(exit) {
|
|
210
224
|
exit.speedEwma = exit.speedEwma === null ? FLOOR_BPS : Math.min(exit.speedEwma, FLOOR_BPS);
|
|
211
225
|
exit.flooredAtMs = Date.now();
|
|
@@ -213,15 +227,53 @@ function floorExit(exit) {
|
|
|
213
227
|
/** Feed one tunnel's rate meter into the exit's speed EWMA (peak-window based).
|
|
214
228
|
* Only transfers past SPEED_MIN_BYTES count — smaller ones never reveal path
|
|
215
229
|
* capacity on a high-RTT link. */
|
|
216
|
-
function sampleSpeed(exit, meter, durMs) {
|
|
230
|
+
function sampleSpeed(exit, meter, durMs, recoveryMinBps = NEAR_DEAD_EXIT_BPS) {
|
|
217
231
|
exit.bytesDown += meter.total;
|
|
218
232
|
if (meter.total < SPEED_MIN_BYTES)
|
|
219
233
|
return;
|
|
220
234
|
const bps = meter.peakBps(durMs);
|
|
221
235
|
if (bps <= 0)
|
|
222
236
|
return;
|
|
237
|
+
if (exit.flooredAtMs !== null) {
|
|
238
|
+
// FLOOR_BPS is a penalty, not a real historical sample. A qualifying probe
|
|
239
|
+
// replaces it with the fresh measurement instead of blending the floor into
|
|
240
|
+
// the result. Only a useful-speed transfer proves recovery; a completed but
|
|
241
|
+
// still-too-slow probe remains floored for another redemption interval.
|
|
242
|
+
// Peak-window speed is useful for ranking paths, but HLS redemption must
|
|
243
|
+
// also prove whole-segment average throughput. A probe observed at a 0.9
|
|
244
|
+
// Mbit/s peak but only 0.6 Mbit/s average caused six-way prefetch/hedge
|
|
245
|
+
// congestion when it rejoined a 1.8 Mbit/s stream.
|
|
246
|
+
const averageBps = durMs > 0 ? (meter.total / durMs) * 1000 : 0;
|
|
247
|
+
if (isUsefulRecoverySample(bps, averageBps, recoveryMinBps)) {
|
|
248
|
+
exit.speedEwma = bps;
|
|
249
|
+
exit.flooredAtMs = null;
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
exit.speedEwma = FLOOR_BPS;
|
|
253
|
+
exit.flooredAtMs = Date.now();
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
223
257
|
exit.speedEwma = exit.speedEwma === null ? bps : SPEED_EWMA_KEEP * exit.speedEwma + (1 - SPEED_EWMA_KEEP) * bps;
|
|
224
|
-
|
|
258
|
+
}
|
|
259
|
+
/** Choose exits safe for a foreground HLS fetch. Once any proven-fast exit is
|
|
260
|
+
* available, unproven/redeeming exits cannot borrow its score and displace it.
|
|
261
|
+
* With no proven exit, unproven exits remain the startup/failover path; if every
|
|
262
|
+
* exit is degraded, keep the original pool as a last resort. Exported for the
|
|
263
|
+
* policy regression tests. */
|
|
264
|
+
export function selectHlsPrimaryPool(pool) {
|
|
265
|
+
const proven = pool.filter((e) => e.flooredAtMs === null && e.speedEwma !== null && e.speedEwma >= HLS_DELIVERY_BPS);
|
|
266
|
+
if (proven.length > 0)
|
|
267
|
+
return proven;
|
|
268
|
+
const unproven = pool.filter((e) => e.flooredAtMs === null && e.speedEwma === null);
|
|
269
|
+
return unproven.length > 0 ? unproven : pool;
|
|
270
|
+
}
|
|
271
|
+
/** Dynamic prefetch concurrency follows only the foreground-safe pool. */
|
|
272
|
+
export function countHlsDeliveryExits(pool) {
|
|
273
|
+
// Two adjacent live segments in flight keep the buffer moving without the
|
|
274
|
+
// runaway 6-8 request fan-out seen when every marginal exit increased both
|
|
275
|
+
// prefetch concurrency and whole-segment hedging.
|
|
276
|
+
return Math.min(2, Math.max(1, selectHlsPrimaryPool(pool).length));
|
|
225
277
|
}
|
|
226
278
|
function fmtMbps(bps) {
|
|
227
279
|
return bps === null ? "?" : `${((bps * 8) / 1e6).toFixed(1)}Mbps`;
|
|
@@ -1042,7 +1094,41 @@ export function startMultiExitRouter(opts) {
|
|
|
1042
1094
|
}
|
|
1043
1095
|
// ---- HLS segment prefetch (plain-HTTP streams only) ------------------------
|
|
1044
1096
|
// Fetch one URL through the region's exit pool, buffered, with failover.
|
|
1045
|
-
|
|
1097
|
+
let redemptionProbeInFlight = false;
|
|
1098
|
+
let lastRedemptionProbeMs = 0;
|
|
1099
|
+
function maybeStartRedemptionProbe(u, extraHeaders, region) {
|
|
1100
|
+
const now = Date.now();
|
|
1101
|
+
if (redemptionProbeInFlight || now - lastRedemptionProbeMs < REDEMPTION_PROBE_GAP_MS)
|
|
1102
|
+
return;
|
|
1103
|
+
const candidate = exits
|
|
1104
|
+
.filter((e) => e.region === region && e.flooredAtMs !== null &&
|
|
1105
|
+
now - e.flooredAtMs >= REDEMPTION_MS && e.trippedUntil <= now &&
|
|
1106
|
+
(e.healthy || (e.lastSuccessMs !== null && now - e.lastSuccessMs < RECENT_SUCCESS_MS)))
|
|
1107
|
+
.sort((a, b) => (a.flooredAtMs ?? now) - (b.flooredAtMs ?? now))[0];
|
|
1108
|
+
if (!candidate)
|
|
1109
|
+
return;
|
|
1110
|
+
redemptionProbeInFlight = true;
|
|
1111
|
+
lastRedemptionProbeMs = now;
|
|
1112
|
+
// Re-arm before dispatch so a failed/short probe cannot be started again by
|
|
1113
|
+
// every concurrent playlist refresh.
|
|
1114
|
+
candidate.flooredAtMs = now;
|
|
1115
|
+
log(` redemption-probe ${candidate.name}: isolated background segment test`);
|
|
1116
|
+
void fetchViaRegionBuffered(u, extraHeaders, "redemption-probe", undefined, undefined, candidate)
|
|
1117
|
+
.then(() => {
|
|
1118
|
+
if (candidate.flooredAtMs === null) {
|
|
1119
|
+
log(` redemption-probe ${candidate.name}: RECOVERED at ${fmtMbps(candidate.speedEwma)}`);
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
log(` redemption-probe ${candidate.name}: still degraded — keeping it off foreground HLS`);
|
|
1123
|
+
}
|
|
1124
|
+
})
|
|
1125
|
+
.catch((e) => {
|
|
1126
|
+
floorExit(candidate);
|
|
1127
|
+
log(` redemption-probe ${candidate.name}: failed (${e.message}) — keeping it off foreground HLS`);
|
|
1128
|
+
})
|
|
1129
|
+
.finally(() => { redemptionProbeInFlight = false; });
|
|
1130
|
+
}
|
|
1131
|
+
function fetchViaRegionBuffered(u, extraHeaders, label, signal, exitHint, forcedExit) {
|
|
1046
1132
|
return new Promise((resolvep, rejectp) => {
|
|
1047
1133
|
let settled = false;
|
|
1048
1134
|
let curReq = null;
|
|
@@ -1077,7 +1163,9 @@ export function startMultiExitRouter(opts) {
|
|
|
1077
1163
|
reject(new Error("no region for host"));
|
|
1078
1164
|
return;
|
|
1079
1165
|
}
|
|
1080
|
-
|
|
1166
|
+
if (!forcedExit && label === "prefetch")
|
|
1167
|
+
maybeStartRedemptionProbe(u, extraHeaders, region);
|
|
1168
|
+
const base0 = forcedExit ? [forcedExit] : pickOrder(region);
|
|
1081
1169
|
if (base0.length === 0) {
|
|
1082
1170
|
reject(new Error("no healthy exit"));
|
|
1083
1171
|
return;
|
|
@@ -1092,7 +1180,10 @@ export function startMultiExitRouter(opts) {
|
|
|
1092
1180
|
// moderately-slow ones (0.4–0.6 Mbps) that we WANT for aggregation. A
|
|
1093
1181
|
// single (non-striped) fetch instead skips near-dead exits and rotates.
|
|
1094
1182
|
let order;
|
|
1095
|
-
if (
|
|
1183
|
+
if (forcedExit) {
|
|
1184
|
+
order = base0;
|
|
1185
|
+
}
|
|
1186
|
+
else if (exitHint !== undefined) {
|
|
1096
1187
|
// Striped range: use pickOrder's live LOAD-AWARE order directly (base0 is
|
|
1097
1188
|
// sorted by speed/(active+1), least-loaded-fastest first). Because each
|
|
1098
1189
|
// buffered fetch now increments exit.active at connect time, concurrent
|
|
@@ -1110,8 +1201,7 @@ export function startMultiExitRouter(opts) {
|
|
|
1110
1201
|
// splitting one exit's bandwidth N ways (observed: 3 segments all on
|
|
1111
1202
|
// 1.15 at ~1Mbps each while 1.16 sat idle). A blind RR rotation kept
|
|
1112
1203
|
// colliding because it ignored who was actually busy.
|
|
1113
|
-
|
|
1114
|
-
order = fast.length > 0 ? fast : base0;
|
|
1204
|
+
order = selectHlsPrimaryPool(base0);
|
|
1115
1205
|
}
|
|
1116
1206
|
const path = (u.pathname || "/") + (u.search || "");
|
|
1117
1207
|
const tail = (u.pathname || "/").split("/").pop() || path;
|
|
@@ -1123,7 +1213,8 @@ export function startMultiExitRouter(opts) {
|
|
|
1123
1213
|
// bad luck; a fresh retry usually succeeds. Loop the whole exit order a
|
|
1124
1214
|
// few rounds before giving up, so the player almost never sees a hard
|
|
1125
1215
|
// failure (which trips its error watchdog → error.html → black screen).
|
|
1126
|
-
|
|
1216
|
+
const maxRounds = forcedExit ? 1 : MAX_FETCH_ROUNDS;
|
|
1217
|
+
if (round + 1 < maxRounds) {
|
|
1127
1218
|
const t = setTimeout(() => tryIdx(0, round + 1), 150);
|
|
1128
1219
|
t.unref?.();
|
|
1129
1220
|
return;
|
|
@@ -1201,7 +1292,7 @@ export function startMultiExitRouter(opts) {
|
|
|
1201
1292
|
nextExit(`empty ${status}`);
|
|
1202
1293
|
return;
|
|
1203
1294
|
}
|
|
1204
|
-
sampleSpeed(exit, meter, durMs);
|
|
1295
|
+
sampleSpeed(exit, meter, durMs, HLS_DELIVERY_BPS);
|
|
1205
1296
|
if (label) {
|
|
1206
1297
|
const kb = (body.length / 1024).toFixed(0);
|
|
1207
1298
|
const mbps = durMs > 0 ? ((body.length * 8) / durMs / 1000).toFixed(1) : "?";
|
|
@@ -1268,10 +1359,9 @@ export function startMultiExitRouter(opts) {
|
|
|
1268
1359
|
if (!fallbackRegion)
|
|
1269
1360
|
return 1;
|
|
1270
1361
|
const now = Date.now();
|
|
1271
|
-
const
|
|
1272
|
-
(e.healthy || (e.lastSuccessMs !== null && now - e.lastSuccessMs < RECENT_SUCCESS_MS))
|
|
1273
|
-
|
|
1274
|
-
return Math.max(1, n);
|
|
1362
|
+
const eligible = exits.filter((e) => e.region === fallbackRegion && e.trippedUntil <= now &&
|
|
1363
|
+
(e.healthy || (e.lastSuccessMs !== null && now - e.lastSuccessMs < RECENT_SUCCESS_MS)));
|
|
1364
|
+
return countHlsDeliveryExits(eligible);
|
|
1275
1365
|
};
|
|
1276
1366
|
const usableChinaExits = () => Math.min(AGG, deliveringChinaExits());
|
|
1277
1367
|
// Prefetch parallelism = the number of exits ACTUALLY delivering right now
|
|
@@ -1565,17 +1655,6 @@ export function startMultiExitRouter(opts) {
|
|
|
1565
1655
|
});
|
|
1566
1656
|
if (mode === "loadbalance") {
|
|
1567
1657
|
const reporter = setInterval(() => {
|
|
1568
|
-
// Redemption: lift an expired speed-floor back to "unproven" so the exit
|
|
1569
|
-
// gets a fresh segment chance (only segments can re-prove it; without
|
|
1570
|
-
// this a floor is a permanent exclusion, which we never want).
|
|
1571
|
-
const rnow = Date.now();
|
|
1572
|
-
for (const e of exits) {
|
|
1573
|
-
if (e.flooredAtMs !== null && rnow - e.flooredAtMs > REDEMPTION_MS) {
|
|
1574
|
-
e.flooredAtMs = null;
|
|
1575
|
-
e.speedEwma = null;
|
|
1576
|
-
log(`${e.name}: floor lifted after ${Math.round(REDEMPTION_MS / 1000)}s — giving it another chance`);
|
|
1577
|
-
}
|
|
1578
|
-
}
|
|
1579
1658
|
const parts = exits.filter((e) => e.healthy).map((e) => `${e.name}[${e.region}]: ${e.active} active / ${e.served} total / ${fmtMbps(e.speedEwma)}`);
|
|
1580
1659
|
parts.push(`direct: ${directActive} active / ${directTotal} total`);
|
|
1581
1660
|
log(`load — ${parts.join(" | ")}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.192",
|
|
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",
|