@decentnetwork/lan 0.1.150 → 0.1.154
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.
- package/bin/tun-helper-darwin-amd64 +0 -0
- package/bin/tun-helper-darwin-arm64 +0 -0
- package/bin/tun-helper-linux-amd64 +0 -0
- package/bin/tun-helper-linux-arm64 +0 -0
- package/dist/proxy/multi-exit-router.js +55 -6
- package/dist/router/paced-forwarder.d.ts +10 -2
- package/dist/router/paced-forwarder.js +70 -2
- package/dist/router/packet-router.js +8 -10
- package/dist/types.d.ts +4 -0
- package/dist/ui/desktop/app.js +19 -7
- package/dist/ui/server.js +21 -2
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -114,6 +114,7 @@ const STALL_MIN_OPEN_MS = 4000; // only a tunnel open at least this long can cou
|
|
|
114
114
|
const STALL_MIN_BYTES = 16 * 1024; // delivered at least this = a healthy, real segment
|
|
115
115
|
const STALL_DEAD_BYTES = 1024; // relayed LESS than this = the exit is genuinely dead (only THIS counts as a stall); a small-but-real request (playlist/key/keep-alive, 1-16 KB) is NEUTRAL, so it can't flap a healthy exit out of the pool
|
|
116
116
|
const TRIP_AFTER_STALLS = 3; // consecutive DEAD tunnels before an exit is tripped out (hysteresis)
|
|
117
|
+
const TRIP_AFTER_UPSTREAM_FAILS = 5; // consecutive non-200 CONNECT responses (exit's proxy broken, e.g. 502s everything) before tripping
|
|
117
118
|
const TRIP_COOLDOWN_MS = 30_000; // how long a tripped exit stays excluded before it's retried
|
|
118
119
|
// Measures peak short-window throughput of a byte stream. Feed it every chunk
|
|
119
120
|
// length via add(); read peakBps at the end. Peak (not average) shrugs off TCP
|
|
@@ -196,6 +197,7 @@ export function startMultiExitRouter(opts) {
|
|
|
196
197
|
served: 0,
|
|
197
198
|
lastRtt: null,
|
|
198
199
|
lastSuccessMs: null,
|
|
200
|
+
consecUpstreamFails: 0,
|
|
199
201
|
speedEwma: null,
|
|
200
202
|
bytesDown: 0,
|
|
201
203
|
stalls: 0,
|
|
@@ -334,10 +336,17 @@ export function startMultiExitRouter(opts) {
|
|
|
334
336
|
}
|
|
335
337
|
// A tunnel established but stalled (delivered ~nothing) → count it; trip the
|
|
336
338
|
// exit out of the pool once it stalls repeatedly, so a flaky node stops
|
|
337
|
-
// getting traffic instead of stalling segments.
|
|
339
|
+
// getting traffic instead of stalling segments. BUT never trip an exit the
|
|
340
|
+
// health PROBE still says is UP: under a busy live stream on a congested
|
|
341
|
+
// cross-border path, healthy exits routinely stall a segment or idle a small
|
|
342
|
+
// request — tripping them there collapses the pool (5↔3) and makes the stream
|
|
343
|
+
// LESS stable, not more. The probe (a real round-trip) is the down-detector;
|
|
344
|
+
// the stall breaker only fires on an exit that is ALSO failing its probe.
|
|
338
345
|
function recordStall(exit, why) {
|
|
339
346
|
exit.stalls++;
|
|
340
|
-
|
|
347
|
+
const probeAlive = exit.healthy ||
|
|
348
|
+
(exit.lastSuccessMs !== null && Date.now() - exit.lastSuccessMs < RECENT_SUCCESS_MS);
|
|
349
|
+
if (!probeAlive && exit.stalls >= TRIP_AFTER_STALLS && exit.trippedUntil <= Date.now()) {
|
|
341
350
|
exit.trippedUntil = Date.now() + TRIP_COOLDOWN_MS;
|
|
342
351
|
exit.healthy = false;
|
|
343
352
|
log(`✗ ${exit.name} [${exit.region}] TRIPPED for ${TRIP_COOLDOWN_MS / 1000}s (${why}) — excluding from pool`);
|
|
@@ -413,7 +422,17 @@ export function startMultiExitRouter(opts) {
|
|
|
413
422
|
// problem — it fails identically on every exit, so tripping the exit for
|
|
414
423
|
// it churns the whole pool (a single dead host like sdc3.10086.cn or
|
|
415
424
|
// uem.migu.cn was knocking out all 6 China exits). Just fail over.
|
|
416
|
-
|
|
425
|
+
//
|
|
426
|
+
// AND: a connect-timeout to an exit the health probe STILL says is UP is
|
|
427
|
+
// transient cross-border congestion under load — NOT a dead exit (proven:
|
|
428
|
+
// the exit answers its probe at ~200ms and reaches the origin in ~40ms).
|
|
429
|
+
// Tripping it here removes it from the pool, piles load onto fewer exits,
|
|
430
|
+
// causes MORE timeouts → the pool collapses to 1-2 and the whole stream
|
|
431
|
+
// stalls (the high-bitrate live-video failure mode). Only trip an exit
|
|
432
|
+
// that is ALSO failing its probe (genuinely unreachable); a healthy exit
|
|
433
|
+
// just fails this ONE connection over and stays in the pool.
|
|
434
|
+
const probeAlive = exit.healthy || (exit.lastSuccessMs !== null && Date.now() - exit.lastSuccessMs < RECENT_SUCCESS_MS);
|
|
435
|
+
if (tripExit && !probeAlive)
|
|
417
436
|
recordStall(exit, why);
|
|
418
437
|
up.destroy();
|
|
419
438
|
onFail(why);
|
|
@@ -441,6 +460,7 @@ export function startMultiExitRouter(opts) {
|
|
|
441
460
|
// tunnels are flowing.
|
|
442
461
|
exit.lastSuccessMs = Date.now();
|
|
443
462
|
exit.fails = 0;
|
|
463
|
+
exit.consecUpstreamFails = 0;
|
|
444
464
|
if (!exit.healthy) {
|
|
445
465
|
exit.healthy = true;
|
|
446
466
|
log(`✓ ${exit.name} [${exit.region}] UP (live traffic)`);
|
|
@@ -510,7 +530,20 @@ export function startMultiExitRouter(opts) {
|
|
|
510
530
|
up.on("error", () => client.destroy());
|
|
511
531
|
}
|
|
512
532
|
else {
|
|
513
|
-
// Exit answered
|
|
533
|
+
// Exit answered with a non-200 CONNECT status. Usually the ORIGIN's
|
|
534
|
+
// fault (a dead host 502s on EVERY exit — tripping would churn the pool),
|
|
535
|
+
// so we just fail over. BUT if THIS exit returns non-200 for many targets
|
|
536
|
+
// in a row (reset on any success), its own proxy is broken — e.g. it 502s
|
|
537
|
+
// everything (dead upstream / wrong version). Trip it so it stops eating
|
|
538
|
+
// the first pick on every request. A single dead origin can't reach the
|
|
539
|
+
// threshold: other hosts still succeed on this exit and reset the streak.
|
|
540
|
+
exit.consecUpstreamFails++;
|
|
541
|
+
if (exit.consecUpstreamFails >= TRIP_AFTER_UPSTREAM_FAILS && exit.trippedUntil <= Date.now()) {
|
|
542
|
+
exit.trippedUntil = Date.now() + TRIP_COOLDOWN_MS;
|
|
543
|
+
exit.healthy = false;
|
|
544
|
+
log(`✗ ${exit.name} [${exit.region}] TRIPPED for ${TRIP_COOLDOWN_MS / 1000}s (proxy returned ${status.trim()} for ${exit.consecUpstreamFails} targets in a row) — excluding from pool`);
|
|
545
|
+
printPool();
|
|
546
|
+
}
|
|
514
547
|
fail(`upstream ${status}`, false);
|
|
515
548
|
}
|
|
516
549
|
};
|
|
@@ -774,9 +807,20 @@ export function startMultiExitRouter(opts) {
|
|
|
774
807
|
? () => tls.connect({ socket: tun, servername: host, rejectUnauthorized: false })
|
|
775
808
|
: () => tun;
|
|
776
809
|
const up = reqFn({ host, port, path, method: req.method, headers, createConnection: conn }, (upRes) => {
|
|
810
|
+
log(` MITM ${req.method} ${host}${(path || "/").slice(0, 80)} → ${upRes.statusCode}`);
|
|
777
811
|
const meter = new RateMeter();
|
|
778
|
-
|
|
779
|
-
|
|
812
|
+
// Sniff the stream-decision APIs' bodies (they say play:1+url vs play:0)
|
|
813
|
+
// so we can see WHY the app won't play even though every request is 200.
|
|
814
|
+
const sniff = /vdn\/live|cctv5\.json|getHttppdrp|\/vdn\//.test(path);
|
|
815
|
+
let sbuf = sniff ? Buffer.alloc(0) : null;
|
|
816
|
+
const enc = String(upRes.headers["content-encoding"] || "");
|
|
817
|
+
upRes.on("data", (d) => { meter.add(d.length, Date.now()); if (sbuf && sbuf.length < 1500)
|
|
818
|
+
sbuf = Buffer.concat([sbuf, d]); });
|
|
819
|
+
upRes.once("end", () => {
|
|
820
|
+
sampleSpeed(exit, meter, Date.now() - t0);
|
|
821
|
+
if (sbuf)
|
|
822
|
+
log(` body[${host}${enc ? " " + enc : ""}]: ${sbuf.slice(0, 1000).toString("utf8").replace(/\s+/g, " ")}`);
|
|
823
|
+
});
|
|
780
824
|
res.writeHead(upRes.statusCode || 502, upRes.headers);
|
|
781
825
|
upRes.pipe(res);
|
|
782
826
|
});
|
|
@@ -930,6 +974,11 @@ export function startMultiExitRouter(opts) {
|
|
|
930
974
|
}
|
|
931
975
|
fetchViaRegionBuffered(u, fwd, isPlaylist ? "playlist" : "segment")
|
|
932
976
|
.then((r) => {
|
|
977
|
+
log(` MITM ${method} ${host}${(path || "/").slice(0, 80)} → ${r.status} (${r.body.length}B)`);
|
|
978
|
+
if (r.status >= 400) {
|
|
979
|
+
const snip = r.body.slice(0, 300).toString("utf8").replace(/\s+/g, " ");
|
|
980
|
+
log(` body: ${snip}`);
|
|
981
|
+
}
|
|
933
982
|
const outHeaders = { ...r.headers };
|
|
934
983
|
delete outHeaders["transfer-encoding"];
|
|
935
984
|
outHeaders["content-length"] = r.body.length;
|
|
@@ -25,9 +25,17 @@ export interface PacedForwarderStats {
|
|
|
25
25
|
export declare class PacedForwarder {
|
|
26
26
|
#private;
|
|
27
27
|
constructor(opts: PacedForwarderOptions);
|
|
28
|
-
/** Set the paced target rate (bytes/sec) for a destination
|
|
29
|
-
*
|
|
28
|
+
/** Set the paced target rate (bytes/sec) for a destination directly. Kept for
|
|
29
|
+
* tests / explicit control; the live path uses reportDelivered. */
|
|
30
30
|
setRate(dstIp: string, rateBps: number): void;
|
|
31
|
+
/** Feed a peer's measured delivered-rate: bytes/sec of the traffic we sent
|
|
32
|
+
* them that actually landed. Loss-driven: over a rolling window we compare it
|
|
33
|
+
* to what we sent and only TIGHTEN the pace when the path is dropping a real
|
|
34
|
+
* fraction of our egress; a clean path ramps the pace back toward uncapped.
|
|
35
|
+
* This is what stops a bursty app (HLS video) — whose per-second delivered
|
|
36
|
+
* rate swings between a full segment and an idle trough — from collapsing the
|
|
37
|
+
* pace and never recovering. */
|
|
38
|
+
reportDelivered(dstIp: string, deliveredBps: number): void;
|
|
31
39
|
/** Enqueue one IP packet for `dstIp` under flow `flowKey`. Never blocks; the
|
|
32
40
|
* drain loop releases it paced + fairly. Returns false if it was dropped
|
|
33
41
|
* (queue overflow). */
|
|
@@ -31,6 +31,25 @@ const DEFAULT_MAX_FLOW_BYTES = 256 * 1024;
|
|
|
31
31
|
const DEFAULT_MAX_DEST_BYTES = 4 * 1024 * 1024;
|
|
32
32
|
const DEFAULT_BURST_SECONDS = 0.05; // 50ms of rate as burst — smooths without adding latency
|
|
33
33
|
const MIN_RATE_BPS = 32 * 1024; // never pace below 256 kbps
|
|
34
|
+
// LOSS-DRIVEN pacing. The old rule paced to `delivered × headroom`, but the
|
|
35
|
+
// delivered rate is itself CAPPED by the pace — a classic self-limiting loop: a
|
|
36
|
+
// single low sample (a bursty app's idle trough between HLS segments, or a slow
|
|
37
|
+
// first segment) drags the pace down, which caps delivery, which keeps the next
|
|
38
|
+
// sample low… and it never climbs back. That throttled healthy paths to a
|
|
39
|
+
// fraction of capacity (measured: CCTV stuck at 0.3–0.5 Mbps even over an
|
|
40
|
+
// all-local route with no congestion at all).
|
|
41
|
+
//
|
|
42
|
+
// Instead: judge the path over a rolling window by comparing what we SENT to
|
|
43
|
+
// what the peer reports DELIVERED. Only real loss (a chunk of our egress not
|
|
44
|
+
// landing) tightens the pace; a clean path ramps the pace back toward uncapped.
|
|
45
|
+
// So we never throttle a healthy path, yet still back off the instant we
|
|
46
|
+
// over-drive a congested one and induce drops. The window absorbs the timing
|
|
47
|
+
// skew between our egress and the peer's 1s report so a burst isn't misread as
|
|
48
|
+
// loss.
|
|
49
|
+
const LOSS_WINDOW_MS = 2000;
|
|
50
|
+
const LOSS_TRIGGER = 0.2; // >20% of sent bytes not landing ⇒ real congestion
|
|
51
|
+
const PROBE_GAIN = 1.5; // clean path: ramp the pace up briskly toward uncapped
|
|
52
|
+
const BACKOFF_GAIN = 1.1; // lossy path: pace just above what actually gets through
|
|
34
53
|
class Dest {
|
|
35
54
|
rateBps;
|
|
36
55
|
tokens;
|
|
@@ -41,11 +60,19 @@ class Dest {
|
|
|
41
60
|
cursor = 0;
|
|
42
61
|
totalBytes = 0;
|
|
43
62
|
draining = false;
|
|
63
|
+
// Loss-window accounting: bytes we've handed to the carrier and bytes the peer
|
|
64
|
+
// reports as delivered, since winStartMs. Compared once per LOSS_WINDOW_MS.
|
|
65
|
+
winSentBytes = 0;
|
|
66
|
+
winDeliveredBytes = 0;
|
|
67
|
+
winStartMs;
|
|
68
|
+
lastReportMs;
|
|
44
69
|
constructor(rateBps, burstSeconds, nowMs) {
|
|
45
70
|
this.rateBps = rateBps;
|
|
46
71
|
this.burstBytes = Math.max(1500, rateBps * burstSeconds);
|
|
47
72
|
this.tokens = this.burstBytes;
|
|
48
73
|
this.lastRefillMs = nowMs;
|
|
74
|
+
this.winStartMs = nowMs;
|
|
75
|
+
this.lastReportMs = nowMs;
|
|
49
76
|
}
|
|
50
77
|
refill(nowMs) {
|
|
51
78
|
const dt = nowMs - this.lastRefillMs;
|
|
@@ -77,12 +104,52 @@ export class PacedForwarder {
|
|
|
77
104
|
schedule: opts.schedule ?? ((fn, ms) => { setTimeout(fn, ms).unref?.(); }),
|
|
78
105
|
};
|
|
79
106
|
}
|
|
80
|
-
/** Set the paced target rate (bytes/sec) for a destination
|
|
81
|
-
*
|
|
107
|
+
/** Set the paced target rate (bytes/sec) for a destination directly. Kept for
|
|
108
|
+
* tests / explicit control; the live path uses reportDelivered. */
|
|
82
109
|
setRate(dstIp, rateBps) {
|
|
83
110
|
const d = this.#dest(dstIp);
|
|
84
111
|
d.setRate(rateBps, this.#opts.burstSeconds);
|
|
85
112
|
}
|
|
113
|
+
/** Feed a peer's measured delivered-rate: bytes/sec of the traffic we sent
|
|
114
|
+
* them that actually landed. Loss-driven: over a rolling window we compare it
|
|
115
|
+
* to what we sent and only TIGHTEN the pace when the path is dropping a real
|
|
116
|
+
* fraction of our egress; a clean path ramps the pace back toward uncapped.
|
|
117
|
+
* This is what stops a bursty app (HLS video) — whose per-second delivered
|
|
118
|
+
* rate swings between a full segment and an idle trough — from collapsing the
|
|
119
|
+
* pace and never recovering. */
|
|
120
|
+
reportDelivered(dstIp, deliveredBps) {
|
|
121
|
+
const d = this.#dest(dstIp);
|
|
122
|
+
const now = this.#opts.now();
|
|
123
|
+
const dt = now - d.lastReportMs;
|
|
124
|
+
d.lastReportMs = now;
|
|
125
|
+
if (dt > 0)
|
|
126
|
+
d.winDeliveredBytes += (deliveredBps * dt) / 1000;
|
|
127
|
+
const winMs = now - d.winStartMs;
|
|
128
|
+
if (winMs < LOSS_WINDOW_MS)
|
|
129
|
+
return;
|
|
130
|
+
const sent = d.winSentBytes;
|
|
131
|
+
const delivered = d.winDeliveredBytes;
|
|
132
|
+
const winSec = winMs / 1000;
|
|
133
|
+
d.winSentBytes = 0;
|
|
134
|
+
d.winDeliveredBytes = 0;
|
|
135
|
+
d.winStartMs = now;
|
|
136
|
+
// Too little egress this window to judge the path — leave the pace as is
|
|
137
|
+
// (don't let an idle stretch either shrink or grow it).
|
|
138
|
+
if (sent < MIN_RATE_BPS * winSec)
|
|
139
|
+
return;
|
|
140
|
+
const lossRatio = Math.max(0, 1 - delivered / sent);
|
|
141
|
+
const bs = this.#opts.burstSeconds;
|
|
142
|
+
if (lossRatio > LOSS_TRIGGER) {
|
|
143
|
+
// Congestion: a real fraction of what we sent didn't land. Pace down to
|
|
144
|
+
// the throughput that actually gets through (a hair above, to keep probing).
|
|
145
|
+
d.setRate(Math.max(MIN_RATE_BPS, (delivered / winSec) * BACKOFF_GAIN), bs);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
// Clean path: our egress is landing. Ramp the pace back up toward uncapped
|
|
149
|
+
// so we are never the bottleneck on a healthy path.
|
|
150
|
+
d.setRate(Math.min(this.#opts.defaultRateBps, d.rateBps * PROBE_GAIN), bs);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
86
153
|
/** Enqueue one IP packet for `dstIp` under flow `flowKey`. Never blocks; the
|
|
87
154
|
* drain loop releases it paced + fairly. Returns false if it was dropped
|
|
88
155
|
* (queue overflow). */
|
|
@@ -175,6 +242,7 @@ export class PacedForwarder {
|
|
|
175
242
|
f.bytes -= size;
|
|
176
243
|
d.totalBytes -= size;
|
|
177
244
|
d.tokens -= size;
|
|
245
|
+
d.winSentBytes += size;
|
|
178
246
|
this.#sent++;
|
|
179
247
|
void this.#opts.send(dstIp, pkt).catch(() => { this.#dropped++; });
|
|
180
248
|
d.cursor = (d.cursor + 1) % Math.max(1, d.order.length);
|
|
@@ -14,12 +14,9 @@ const SUBNET_PREFIX = "10.86.";
|
|
|
14
14
|
// How often each node reports the delivered rate of a peer's inbound flow.
|
|
15
15
|
const RATE_REPORT_INTERVAL_MS = 1000;
|
|
16
16
|
// Only report for peers whose inbound this interval exceeds this — no point
|
|
17
|
-
// pacing a trickle (SSH/keepalives), and it keeps reports sparse.
|
|
17
|
+
// pacing a trickle (SSH/keepalives), and it keeps reports sparse. The pacer's
|
|
18
|
+
// loss window derives its verdict from these reports vs. what it sent.
|
|
18
19
|
const RATE_REPORT_MIN_BYTES = 32 * 1024; // ~256 kbps sustained
|
|
19
|
-
// The sender paces to measured × headroom so throughput can still probe upward
|
|
20
|
-
// when the path frees up; loss caps it back down via the next report.
|
|
21
|
-
const RATE_REPORT_HEADROOM = 1.25;
|
|
22
|
-
const RATE_REPORT_MIN_BPS = 64 * 1024;
|
|
23
20
|
export class PacketRouter extends EventEmitter {
|
|
24
21
|
tunDevice;
|
|
25
22
|
peerManager;
|
|
@@ -71,15 +68,16 @@ export class PacketRouter extends EventEmitter {
|
|
|
71
68
|
this.stats.packetsForwarded++;
|
|
72
69
|
},
|
|
73
70
|
});
|
|
74
|
-
// A peer's rate report tells us how
|
|
75
|
-
//
|
|
76
|
-
//
|
|
71
|
+
// A peer's rate report tells us how much of our traffic to them actually
|
|
72
|
+
// landed. Feed it to the loss-driven pacer: it compares this to what we
|
|
73
|
+
// sent over a rolling window and only tightens the pace on real loss — a
|
|
74
|
+
// clean path stays uncapped, so a bursty app (HLS video) is never
|
|
75
|
+
// throttled by us and an idle trough can't collapse the pace.
|
|
77
76
|
this.peerManager.on("rate-report", (pubkey, bytesPerSec) => {
|
|
78
77
|
const mapping = this.ipam.resolveCarrierId(pubkey);
|
|
79
78
|
if (!mapping?.virtualIp || !this.pacer)
|
|
80
79
|
return;
|
|
81
|
-
|
|
82
|
-
this.pacer.setRate(mapping.virtualIp, target);
|
|
80
|
+
this.pacer.reportDelivered(mapping.virtualIp, bytesPerSec);
|
|
83
81
|
});
|
|
84
82
|
}
|
|
85
83
|
this.setupCarrierHandlers();
|
package/dist/types.d.ts
CHANGED
|
@@ -110,6 +110,10 @@ export interface BootstrapNode {
|
|
|
110
110
|
port: number;
|
|
111
111
|
pk: string;
|
|
112
112
|
isTcp?: boolean;
|
|
113
|
+
/** Express relays only: set `false` to reach the relay over plain HTTP
|
|
114
|
+
* instead of HTTPS (the payload is already end-to-end encrypted). Lets a
|
|
115
|
+
* relay run on a raw IP with no TLS cert / domain / ICP filing. */
|
|
116
|
+
tls?: boolean;
|
|
113
117
|
}
|
|
114
118
|
export interface CarrierConfig {
|
|
115
119
|
dataDir: string;
|
package/dist/ui/desktop/app.js
CHANGED
|
@@ -173,15 +173,24 @@ async function dkGet(path) {
|
|
|
173
173
|
return r.json();
|
|
174
174
|
}
|
|
175
175
|
async function dkPost(path, body) {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
headers: { "content-type": "application/json" },
|
|
179
|
-
body: JSON.stringify(body || {})
|
|
180
|
-
});
|
|
176
|
+
const ctrl = new AbortController();
|
|
177
|
+
const timer = setTimeout(() => ctrl.abort(), 15e3);
|
|
181
178
|
try {
|
|
182
|
-
|
|
179
|
+
const r = await fetch(path, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { "content-type": "application/json" },
|
|
182
|
+
body: JSON.stringify(body || {}),
|
|
183
|
+
signal: ctrl.signal
|
|
184
|
+
});
|
|
185
|
+
try {
|
|
186
|
+
return await r.json();
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return { ok: r.ok };
|
|
189
|
+
}
|
|
183
190
|
} catch (e) {
|
|
184
|
-
return { ok:
|
|
191
|
+
return { ok: false, error: e && e.name === "AbortError" ? "timed out" : e && e.message || "network error" };
|
|
192
|
+
} finally {
|
|
193
|
+
clearTimeout(timer);
|
|
185
194
|
}
|
|
186
195
|
}
|
|
187
196
|
function dkCopy(text) {
|
|
@@ -1012,6 +1021,9 @@ function PeerSidebar({ T, peers, requests, activeId, onSelect, onAct, onAdd }) {
|
|
|
1012
1021
|
} else {
|
|
1013
1022
|
setAddState({ kind: "err", msg: r && r.error ? `${T.addFailed}: ${r.error}` : T.addFailed });
|
|
1014
1023
|
}
|
|
1024
|
+
}).catch((e) => {
|
|
1025
|
+
setAddState({ kind: "err", msg: `${T.addFailed}: ${e && e.message || e}` });
|
|
1026
|
+
}).finally(() => {
|
|
1015
1027
|
setTimeout(() => setAddState((s) => s && s.kind !== "sending" ? null : s), 6e3);
|
|
1016
1028
|
});
|
|
1017
1029
|
};
|
package/dist/ui/server.js
CHANGED
|
@@ -665,8 +665,27 @@ export function startFriendUi(opts) {
|
|
|
665
665
|
}
|
|
666
666
|
if (req.method === "POST" && url === "/api/add") {
|
|
667
667
|
const { address } = await readBody(req);
|
|
668
|
-
|
|
669
|
-
|
|
668
|
+
// A friend-request is fire-and-forget: it's delivered when the peer is
|
|
669
|
+
// online and only becomes a friend once they accept. Don't let the UI
|
|
670
|
+
// hang on the network round-trip (DHT lookup / unreachable peer) — kick
|
|
671
|
+
// the send off, but respond within a short window. A fast failure (e.g.
|
|
672
|
+
// a malformed address) still surfaces its real error; a slow send
|
|
673
|
+
// returns optimistic success and continues in the background.
|
|
674
|
+
const sent = opts
|
|
675
|
+
.call({ op: "friend-request", address })
|
|
676
|
+
.then((r) => ({ done: true, r }))
|
|
677
|
+
.catch((e) => ({ done: true, r: { ok: false, error: e instanceof Error ? e.message : String(e) } }));
|
|
678
|
+
const settled = await Promise.race([
|
|
679
|
+
sent,
|
|
680
|
+
new Promise((r) => setTimeout(() => r({ done: false }), 2500)),
|
|
681
|
+
]);
|
|
682
|
+
if (settled.done) {
|
|
683
|
+
sendJson(res, settled.r.ok ? 200 : 400, settled.r);
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
void sent.catch(() => { }); // let it finish in the background, no unhandled rejection
|
|
687
|
+
sendJson(res, 200, { ok: true, queued: true });
|
|
688
|
+
}
|
|
670
689
|
return;
|
|
671
690
|
}
|
|
672
691
|
res.writeHead(404);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.154",
|
|
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",
|