mslxdff 0.1.32 → 0.1.34
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/mslxdff.js +179 -15
- package/package.json +1 -1
- package/src/groups.js +38 -9
- package/src/peers.js +28 -2
- package/src/routes.js +378 -4
- package/src/server.js +10 -2
package/bin/mslxdff.js
CHANGED
|
@@ -14,7 +14,7 @@ import { createAutoSelector } from "../src/auto.js";
|
|
|
14
14
|
import { createPeersService } from "../src/peers.js";
|
|
15
15
|
import { createEventBus } from "../src/events.js";
|
|
16
16
|
import { createGroupsService, createBansService, refreshGroupMembers, syncPeersFromMembers } from "../src/groups.js";
|
|
17
|
-
import { logDir, recentCalls, lastError, appendCall, appendError, appendEvent, recentEvents } from "../src/logs.js";
|
|
17
|
+
import { logDir, recentCalls, lastError, appendCall, appendError, appendEvent, recentEvents, eventsFile, callsFile, errorsFile } from "../src/logs.js";
|
|
18
18
|
|
|
19
19
|
const logs = { appendCall, appendError, appendEvent };
|
|
20
20
|
|
|
@@ -85,6 +85,29 @@ if (args.includes("-uninstall") || args.includes("--uninstall")) {
|
|
|
85
85
|
process.exit(0);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
if (args.includes("-log") || args.includes("--log") || args.includes("-logs") || args.includes("--logs")) {
|
|
89
|
+
const idx = args.findIndex((x) => x === "-log" || x === "--log" || x === "-logs" || x === "--logs");
|
|
90
|
+
const raw = args[idx + 1];
|
|
91
|
+
const n = Number(raw);
|
|
92
|
+
const count = Number.isInteger(n) && n > 0 ? n : 10;
|
|
93
|
+
const file = eventsFile();
|
|
94
|
+
const dir = logDir();
|
|
95
|
+
console.log(`log dir: ${dir}`);
|
|
96
|
+
console.log(`events: ${file}`);
|
|
97
|
+
const evts = recentEvents(count);
|
|
98
|
+
if (!evts.length) {
|
|
99
|
+
console.log(`(no events yet — file empty or not found)`);
|
|
100
|
+
} else {
|
|
101
|
+
console.log(`--- last ${evts.length} event(s) ---`);
|
|
102
|
+
for (const e of evts) console.log(fmtEvent(e));
|
|
103
|
+
}
|
|
104
|
+
// also hint for other logs
|
|
105
|
+
if (count <= 10) {
|
|
106
|
+
console.log(`\nhint: mslxdff -log 100 | calls: ${callsFile()} errors: ${errorsFile()} daemon: ${logFile()}`);
|
|
107
|
+
}
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
|
|
88
111
|
if (args.includes("-status") || args.includes("--status") || args.includes("-s")) {
|
|
89
112
|
await printStatus();
|
|
90
113
|
process.exit(0);
|
|
@@ -257,14 +280,31 @@ if (groupCmd && groupCmd !== "create") {
|
|
|
257
280
|
console.log(`${g.name} (${entries.length} member${entries.length === 1 ? "" : "s"})`);
|
|
258
281
|
// probe every member endpoint concurrently for reachability + latency;
|
|
259
282
|
// display order = state order so the sequence numbers stay stable for -group remove
|
|
283
|
+
// broadband members (relay://) are not probed — they are reachable via leader
|
|
260
284
|
const probes = await Promise.all(
|
|
261
|
-
entries.map(([id, m]) => probeHealth({ id, url: m?.url || id }))
|
|
285
|
+
entries.map(([id, m]) => probeHealth({ id, url: m?.url || id, kind: m?.kind, lastSeen: m?.lastSeen, publicIp: m?.publicIp }))
|
|
262
286
|
);
|
|
263
287
|
const leaderEntry = members.leader ? Object.entries(members).find(([id]) => id === "leader") : null;
|
|
264
288
|
const leaderProbe = leaderEntry ? await probeHealth({ id: "leader", url: leaderEntry[1].url }) : null;
|
|
265
289
|
const display = leaderProbe ? [...probes, leaderProbe] : probes;
|
|
266
290
|
let seq = 0;
|
|
267
291
|
for (const r of display) {
|
|
292
|
+
const m = entries.find(([eid]) => eid === r.id)?.[1] || (r.id === "leader" ? leaderEntry?.[1] : null);
|
|
293
|
+
const isBb = r.kind === "broadband" || String(r.url || "").startsWith("relay://") || m?.kind === "broadband";
|
|
294
|
+
if (isBb) {
|
|
295
|
+
const ago = r.lastSeen ? `${Math.round((Date.now() - r.lastSeen) / 1000)}s ago` : "no heartbeat yet";
|
|
296
|
+
const ip = r.publicIp || m?.publicIp || "?";
|
|
297
|
+
const via = r.stale ? "stale" : `via leader ${ago}`;
|
|
298
|
+
const stateBb = `${via} ip=${ip}`;
|
|
299
|
+
if (r.id === "leader") {
|
|
300
|
+
console.log(` leader ${r.url} ${stateBb}`);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
seq += 1;
|
|
304
|
+
const label = r.id && r.id !== r.url ? ` [${r.id}]` : "";
|
|
305
|
+
console.log(` ${seq}. ${r.url}${label} [broadband] ${stateBb}`);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
268
308
|
const state = r.fail ? `fail ${r.fail}` : `ok ${r.ms}ms`;
|
|
269
309
|
if (r.id === "leader") {
|
|
270
310
|
console.log(` leader ${r.url} ${state}`);
|
|
@@ -311,12 +351,15 @@ if (groupCmd && groupCmd !== "create") {
|
|
|
311
351
|
process.exit(0);
|
|
312
352
|
}
|
|
313
353
|
|
|
314
|
-
// -addtogroup <leader-host> <name>
|
|
354
|
+
// -addtogroup <leader-host> <name> [--broadband]
|
|
315
355
|
const addToGroupIdx = args.findIndex((x) => x === "-addtogroup" || x === "--addtogroup");
|
|
316
356
|
if (addToGroupIdx >= 0) {
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
357
|
+
const rawArgs = args.slice(addToGroupIdx + 1);
|
|
358
|
+
const isBroadband = rawArgs.includes("--broadband");
|
|
359
|
+
const filtered = rawArgs.filter((a) => a !== "--broadband");
|
|
360
|
+
const [leaderHost, name] = filtered;
|
|
361
|
+
if (!leaderHost || !name || filtered.length > 2) {
|
|
362
|
+
console.error("usage: mslxdff -addtogroup <leader-host> <name> [--broadband]");
|
|
320
363
|
process.exit(1);
|
|
321
364
|
}
|
|
322
365
|
const groups = createGroupsService({});
|
|
@@ -325,25 +368,34 @@ if (addToGroupIdx >= 0) {
|
|
|
325
368
|
const leaderUrl = leaderHost.includes("://")
|
|
326
369
|
? leaderHost.replace(/\/+$/, "")
|
|
327
370
|
: `http://${leaderHost}${leaderHost.includes(":") ? "" : ":8989"}`;
|
|
328
|
-
const
|
|
371
|
+
const kind = isBroadband ? "broadband" : "static";
|
|
372
|
+
let joinBody;
|
|
373
|
+
if (isBroadband) {
|
|
374
|
+
const relayId = `relay://${myToken.slice(0, 8)}`;
|
|
375
|
+
joinBody = { name, key: name, leaderUrl, url: relayId, token: myToken, kind: "broadband" };
|
|
376
|
+
} else {
|
|
377
|
+
const myPort = effectivePort();
|
|
378
|
+
joinBody = { name, key: name, leaderUrl, myPort, token: myToken, kind: "static" };
|
|
379
|
+
}
|
|
329
380
|
try {
|
|
330
381
|
const res = await fetch(`${leaderUrl}/v1/groups/join`, {
|
|
331
382
|
method: "POST",
|
|
332
383
|
headers: { "Content-Type": "application/json" },
|
|
333
|
-
body: JSON.stringify(
|
|
384
|
+
body: JSON.stringify(joinBody),
|
|
334
385
|
});
|
|
335
386
|
if (!res.ok) {
|
|
336
387
|
const text = await res.text().catch(() => "");
|
|
337
388
|
throw new Error(`join failed (HTTP ${res.status}): ${text}`);
|
|
338
389
|
}
|
|
339
390
|
const data = await res.json();
|
|
340
|
-
const myUrl = data.you?.url || "";
|
|
341
|
-
|
|
391
|
+
const myUrl = data.you?.url || joinBody.url || "";
|
|
392
|
+
const memberName = isBroadband ? myUrl : myUrl;
|
|
393
|
+
markJoined({ name, leaderUrl, myUrl, memberName, kind });
|
|
342
394
|
const synced = await syncAllJoinedGroups({ peers, groups });
|
|
343
395
|
const s = synced.find((x) => x.name === name);
|
|
344
|
-
console.log(`joined group "${name}" at ${leaderUrl}`);
|
|
396
|
+
console.log(`joined group "${name}" at ${leaderUrl}${isBroadband ? " [broadband]" : ""}`);
|
|
345
397
|
if (s?.error) console.log(` local failover setup failed: ${s.error}`);
|
|
346
|
-
else console.log(` ${s?.added ?? 0} failover target(s) configured`);
|
|
398
|
+
else console.log(` ${s?.added ?? 0} failover target(s) configured${isBroadband ? " (broadband via leader, local 127.0.0.1)" : ""}`);
|
|
347
399
|
} catch (err) {
|
|
348
400
|
console.error(`join failed: ${err.message}`);
|
|
349
401
|
process.exit(1);
|
|
@@ -363,6 +415,7 @@ if (resetBanArg !== null || args.includes("-resetban") || args.includes("--reset
|
|
|
363
415
|
|
|
364
416
|
function markJoined(entry) {
|
|
365
417
|
const { name } = entry;
|
|
418
|
+
if (!entry.kind) entry.kind = "static";
|
|
366
419
|
const list = loadGroupsJoined().filter((g) => g.name !== name);
|
|
367
420
|
saveGroupsJoined([...list, entry]);
|
|
368
421
|
}
|
|
@@ -441,8 +494,15 @@ function errMsg(err) { return String(err?.message || err); }
|
|
|
441
494
|
|
|
442
495
|
// Probe a member's health endpoint concurrently (v1/health preferred,
|
|
443
496
|
// falling back to /health). Returns { id, url, ms, rank } or { id, url, fail }.
|
|
444
|
-
|
|
497
|
+
// broadband relay:// members are not probed — they are reachable via leader relay.
|
|
498
|
+
async function probeHealth({ id, url, kind, lastSeen, publicIp } = {}) {
|
|
445
499
|
if (!url) return { id, url, fail: "no url", rank: 3 };
|
|
500
|
+
const isBb = kind === "broadband" || String(url).startsWith("relay://");
|
|
501
|
+
if (isBb) {
|
|
502
|
+
const staleMs = Number(process.env.MSLXDFF_BROADBAND_STALE_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_STALE_MS) : 90_000;
|
|
503
|
+
const stale = typeof lastSeen === "number" ? Date.now() - lastSeen > staleMs : true;
|
|
504
|
+
return { id, url, kind: "broadband", lastSeen, publicIp, stale, rank: stale ? 2 : 0 };
|
|
505
|
+
}
|
|
446
506
|
const base = String(url).replace(/\/+$/, "");
|
|
447
507
|
const startedAt = Date.now();
|
|
448
508
|
try {
|
|
@@ -477,6 +537,7 @@ async function syncAllJoinedGroups({ peers, groups }) {
|
|
|
477
537
|
memberName: g.memberName,
|
|
478
538
|
url: g.myUrl,
|
|
479
539
|
token: myToken,
|
|
540
|
+
kind: g.kind,
|
|
480
541
|
});
|
|
481
542
|
results.push({
|
|
482
543
|
name: g.name,
|
|
@@ -603,7 +664,8 @@ const bans = createBansService({ windowMs: banWindowMs(), threshold: banThreshol
|
|
|
603
664
|
const isDebug = process.env.MSLXDFF_DEBUG === "1";
|
|
604
665
|
const bus = createEventBus();
|
|
605
666
|
const router = createRouter({ token, upstream, models, auto, logs, peers, maxHops: maxHopsValue(), groups, bans, bus });
|
|
606
|
-
const
|
|
667
|
+
const listenHost = effectiveHost();
|
|
668
|
+
const srv = startServer({ router, signals: !isDebug, host: listenHost });
|
|
607
669
|
|
|
608
670
|
// -debug: push every event straight to this terminal.
|
|
609
671
|
if (isDebug) {
|
|
@@ -664,6 +726,83 @@ const groupSyncTimer = setInterval(() => {
|
|
|
664
726
|
}, groupSyncIntervalMs());
|
|
665
727
|
groupSyncTimer.unref();
|
|
666
728
|
|
|
729
|
+
// Broadband heartbeat + relay poll (only for broadband members)
|
|
730
|
+
const broadbandGroups = () => loadGroupsJoined().filter((g) => g.kind === "broadband" && g.leaderUrl);
|
|
731
|
+
if (broadbandGroups().length) {
|
|
732
|
+
const doHeartbeat = async () => {
|
|
733
|
+
for (const g of broadbandGroups()) {
|
|
734
|
+
try {
|
|
735
|
+
const res = await fetch(`${g.leaderUrl}/v1/groups/relay/heartbeat`, {
|
|
736
|
+
method: "POST",
|
|
737
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
738
|
+
body: JSON.stringify({ name: g.name, group: g.name }),
|
|
739
|
+
signal: AbortSignal.timeout(5000),
|
|
740
|
+
});
|
|
741
|
+
if (!res.ok) {
|
|
742
|
+
const txt = await res.text().catch(() => "");
|
|
743
|
+
console.log(`broadband heartbeat ${g.name}: ${res.status} ${txt.slice(0, 100)}`);
|
|
744
|
+
}
|
|
745
|
+
} catch (err) {
|
|
746
|
+
console.log(`broadband heartbeat ${g.name}: failed — ${errMsg(err)}`);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
const doPoll = async () => {
|
|
751
|
+
for (const g of broadbandGroups()) {
|
|
752
|
+
try {
|
|
753
|
+
const pollRes = await fetch(`${g.leaderUrl}/v1/groups/relay/poll`, {
|
|
754
|
+
method: "POST",
|
|
755
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
756
|
+
body: JSON.stringify({ name: g.name, group: g.name }),
|
|
757
|
+
signal: AbortSignal.timeout(8000),
|
|
758
|
+
});
|
|
759
|
+
if (!pollRes.ok) continue;
|
|
760
|
+
const data = await pollRes.json().catch(() => ({}));
|
|
761
|
+
const items = data.data || [];
|
|
762
|
+
for (const item of items) {
|
|
763
|
+
const { reqId, body } = item;
|
|
764
|
+
let result;
|
|
765
|
+
try {
|
|
766
|
+
const upRes = await upstream.chat(body);
|
|
767
|
+
const ct = upRes.headers.get("content-type") || "";
|
|
768
|
+
const isStream = Boolean(body?.stream) || ct.includes("text/event-stream");
|
|
769
|
+
if (isStream && upRes.body) {
|
|
770
|
+
// collect stream chunks for relay (simplified: buffer then forward as SSE)
|
|
771
|
+
let collected = "";
|
|
772
|
+
for await (const chunk of upRes.body) {
|
|
773
|
+
collected += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
|
774
|
+
}
|
|
775
|
+
result = { status: upRes.status, headers: { "Content-Type": "text/event-stream" }, body: collected };
|
|
776
|
+
} else {
|
|
777
|
+
const txt = await upRes.text();
|
|
778
|
+
let parsed;
|
|
779
|
+
try { parsed = JSON.parse(txt); } catch { parsed = txt; }
|
|
780
|
+
result = { status: upRes.status, headers: { "Content-Type": upRes.headers.get("content-type") || "application/json" }, body: typeof parsed === "string" ? parsed : JSON.stringify(parsed) };
|
|
781
|
+
}
|
|
782
|
+
} catch (err) {
|
|
783
|
+
result = { status: 502, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ error: errMsg(err) }) };
|
|
784
|
+
}
|
|
785
|
+
try {
|
|
786
|
+
await fetch(`${g.leaderUrl}/v1/groups/relay/result`, {
|
|
787
|
+
method: "POST",
|
|
788
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
789
|
+
body: JSON.stringify({ name: g.name, group: g.name, reqId, result }),
|
|
790
|
+
signal: AbortSignal.timeout(5000),
|
|
791
|
+
});
|
|
792
|
+
} catch {}
|
|
793
|
+
}
|
|
794
|
+
} catch {}
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
// initial heartbeat
|
|
798
|
+
doHeartbeat().catch(() => {});
|
|
799
|
+
const hbTimer = setInterval(doHeartbeat, 30_000);
|
|
800
|
+
hbTimer.unref();
|
|
801
|
+
const pollTimer = setInterval(doPoll, 1000);
|
|
802
|
+
pollTimer.unref();
|
|
803
|
+
console.log(`broadband relay: heartbeat 30s + poll 1s for ${broadbandGroups().length} group(s)`);
|
|
804
|
+
}
|
|
805
|
+
|
|
667
806
|
// Auto-update: periodically check npm for a newer mslxdff and restart.
|
|
668
807
|
// Enable with MSLXDFF_AUTO_UPDATE=1 (hourly) or MSLXDFF_AUTO_UPDATE_MS=<ms>.
|
|
669
808
|
// Uses the same npm view/install path as `mslxdff -update`, but runs inside
|
|
@@ -720,6 +859,16 @@ function effectivePort() {
|
|
|
720
859
|
return resolvePort();
|
|
721
860
|
}
|
|
722
861
|
|
|
862
|
+
function effectiveHost() {
|
|
863
|
+
const envHost = process.env.MSLXDFF_HOST || process.env.MSLXDFF_BIND_HOST;
|
|
864
|
+
if (typeof envHost === "string" && envHost.trim()) return envHost.trim();
|
|
865
|
+
try {
|
|
866
|
+
const joined = loadGroupsJoined();
|
|
867
|
+
if (joined.some((g) => g.kind === "broadband")) return "127.0.0.1";
|
|
868
|
+
} catch {}
|
|
869
|
+
return "0.0.0.0";
|
|
870
|
+
}
|
|
871
|
+
|
|
723
872
|
function refreshIntervalMs() {
|
|
724
873
|
const n = Number(process.env.MODELS_REFRESH_MS);
|
|
725
874
|
return Number.isInteger(n) && n > 0 ? n : 2 * 60 * 60 * 1000;
|
|
@@ -793,6 +942,7 @@ Usage:
|
|
|
793
942
|
mslxdff start as a background daemon and exit (status + help if one is already running)
|
|
794
943
|
mslxdff -d start as a background daemon
|
|
795
944
|
mslxdff -status show current status (daemon, models, recent calls, last error)
|
|
945
|
+
mslxdff -log [N] show last N events (default 10, e.g. -log 100)
|
|
796
946
|
mslxdff -model list list the free models this proxy serves (cached)
|
|
797
947
|
mslxdff -model status show per-model health status (normal/limit/error)
|
|
798
948
|
mslxdff -model refresh force-refresh the model cache from the upstream
|
|
@@ -804,7 +954,7 @@ Usage:
|
|
|
804
954
|
mslxdff -showtoken print the current auth token
|
|
805
955
|
mslxdff -refresh-token rotate the auth token (prints the new one)
|
|
806
956
|
mslxdff -creategroup <name> create a group on this node (the group name is the password)
|
|
807
|
-
mslxdff -addtogroup <leader-host> <name> join a group via its leader host (default port 8989)
|
|
957
|
+
mslxdff -addtogroup <leader-host> <name> [--broadband] join a group via its leader host (default port 8989) — broadband: 宽带动态IP成员(经Leader中继,无需公网入站,默认127.0.0.1)
|
|
808
958
|
mslxdff -group sync pull the freshest member list for all joined groups
|
|
809
959
|
mslxdff -group leave <name> leave a group (removes its members from this node)
|
|
810
960
|
mslxdff -group list list groups on this node (numbered members)
|
|
@@ -881,6 +1031,12 @@ async function printStatus() {
|
|
|
881
1031
|
const tags = [];
|
|
882
1032
|
if (id === "leader" && isLeader) tags.push("leader");
|
|
883
1033
|
if (m?.url && m.url === g.myUrl) tags.push("this node");
|
|
1034
|
+
if (m?.kind === "broadband") tags.push("broadband");
|
|
1035
|
+
if (m?.kind === "broadband" && m?.publicIp) tags.push(`ip=${m.publicIp}`);
|
|
1036
|
+
if (m?.kind === "broadband" && m?.lastSeen) {
|
|
1037
|
+
const ago = Math.round((Date.now() - m.lastSeen) / 1000);
|
|
1038
|
+
tags.push(ago < 90 ? `via leader ${ago}s ago` : "stale");
|
|
1039
|
+
}
|
|
884
1040
|
const tag = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
885
1041
|
console.log(` ${m?.url || id}${tag}`);
|
|
886
1042
|
}
|
|
@@ -982,6 +1138,14 @@ function fmtEvent(e) {
|
|
|
982
1138
|
return `${head} forward -> ${e.peer} model=${m(e.model)} hops=${e.hops}${e.retry ? " (retry)" : ""}`;
|
|
983
1139
|
case "peer-error":
|
|
984
1140
|
return `${head} peer err ${e.peer} ${e.status ? `HTTP ${e.status}` : "network"}: ${m(e.message)}`;
|
|
1141
|
+
case "relay-ip-change":
|
|
1142
|
+
return `${head} relay ip ${e.member || e.id || "?"} ${e.oldIp || "?"} -> ${e.newIp || e.publicIp || "?"} via leader`;
|
|
1143
|
+
case "relay-forward":
|
|
1144
|
+
return `${head} relay fwd ${e.target || e.peer || "?"} via leader model=${m(e.model)} hops=${e.hops || 0}`;
|
|
1145
|
+
case "relay-heartbeat":
|
|
1146
|
+
return `${head} relay hb ${e.member || "?"} ip=${e.ip || "?"} lastSeen=${e.lastSeen ? new Date(e.lastSeen).toISOString().slice(11,19) : "?"}`;
|
|
1147
|
+
case "client-abort":
|
|
1148
|
+
return `${head} client abort ${m(e.model)} total=${fmtDur(e.totalMs)}`;
|
|
985
1149
|
case "result":
|
|
986
1150
|
return `${head} result ${e.status} ${m(e.model)} via=${e.via} 响应耗时 ${fmtDur(e.durationMs)}`;
|
|
987
1151
|
default:
|
package/package.json
CHANGED
package/src/groups.js
CHANGED
|
@@ -30,37 +30,60 @@ export function createGroup(name, { file, key = name } = {}) {
|
|
|
30
30
|
// Shared writer: register a member, de-duplicated by url (same ip:port
|
|
31
31
|
// re-registering under a different name updates the existing entry instead of
|
|
32
32
|
// creating a duplicate).
|
|
33
|
-
function writeMember(group, memberName, url, token) {
|
|
33
|
+
function writeMember(group, memberName, url, token, extra = {}) {
|
|
34
34
|
const members = group.members || (group.members = {});
|
|
35
35
|
const byUrl = Object.entries(members).find(([, m]) => m.url === url);
|
|
36
|
+
const entry = { url, token: token || "", ...extra };
|
|
37
|
+
// normalize kind default
|
|
38
|
+
if (!entry.kind) entry.kind = "static";
|
|
36
39
|
if (byUrl) {
|
|
37
|
-
members[byUrl[0]]
|
|
40
|
+
const prev = members[byUrl[0]] || {};
|
|
41
|
+
// merge: keep url/token from new, but preserve fields if not supplied
|
|
42
|
+
const merged = { ...prev, ...entry };
|
|
43
|
+
// ensure url/token from latest call win
|
|
44
|
+
merged.url = url;
|
|
45
|
+
merged.token = token || prev.token || "";
|
|
46
|
+
members[byUrl[0]] = merged;
|
|
38
47
|
return;
|
|
39
48
|
}
|
|
40
49
|
const id = memberName || url;
|
|
41
|
-
|
|
50
|
+
// if id already exists with different url, update that entry too (fallback)
|
|
51
|
+
if (members[id] && members[id].url !== url) {
|
|
52
|
+
// treat as new url, keep old entry but also update
|
|
53
|
+
}
|
|
54
|
+
members[id] = { ...entry, url, token: token || "" };
|
|
42
55
|
}
|
|
43
56
|
|
|
44
57
|
// Leader side: add a member after verifying the join key.
|
|
45
|
-
export function addGroupMember(name, { key, memberName, url, token, file } = {}) {
|
|
58
|
+
export function addGroupMember(name, { key, memberName, url, token, kind, publicIp, lastSeen, status, file } = {}) {
|
|
46
59
|
const groups = loadGroups(file ? { file } : {});
|
|
47
60
|
const group = groups[name];
|
|
48
61
|
if (!group) throw new Error(`group "${name}" not found on this node`);
|
|
49
62
|
if (!verifyGroupKey(key, group.key)) throw new Error("invalid group key");
|
|
50
63
|
if (!url) throw new Error("member url is required");
|
|
51
|
-
|
|
64
|
+
const extra = {};
|
|
65
|
+
if (kind) extra.kind = kind;
|
|
66
|
+
if (publicIp) extra.publicIp = publicIp;
|
|
67
|
+
if (lastSeen) extra.lastSeen = lastSeen;
|
|
68
|
+
if (status) extra.status = status;
|
|
69
|
+
writeMember(group, memberName, url, token, extra);
|
|
52
70
|
saveGroups(groups, file ? { file } : {});
|
|
53
71
|
return group.members;
|
|
54
72
|
}
|
|
55
73
|
|
|
56
74
|
// Leader side: upsert a member without key verification (used by the sync
|
|
57
75
|
// path after the member's bearer token has already been validated).
|
|
58
|
-
export function upsertMember(name, { memberName, url, token, file } = {}) {
|
|
76
|
+
export function upsertMember(name, { memberName, url, token, kind, publicIp, lastSeen, status, file } = {}) {
|
|
59
77
|
const groups = loadGroups(file ? { file } : {});
|
|
60
78
|
const group = groups[name];
|
|
61
79
|
if (!group) return null;
|
|
62
80
|
if (!url) throw new Error("member url is required");
|
|
63
|
-
|
|
81
|
+
const extra = {};
|
|
82
|
+
if (kind) extra.kind = kind;
|
|
83
|
+
if (publicIp) extra.publicIp = publicIp;
|
|
84
|
+
if (lastSeen) extra.lastSeen = lastSeen;
|
|
85
|
+
if (status) extra.status = status;
|
|
86
|
+
writeMember(group, memberName, url, token, extra);
|
|
64
87
|
saveGroups(groups, file ? { file } : {});
|
|
65
88
|
return group.members;
|
|
66
89
|
}
|
|
@@ -136,17 +159,19 @@ export function createGroupsService({ file } = {}) {
|
|
|
136
159
|
|
|
137
160
|
// Re-register with the leader (join is idempotent) and return the fresh member list.
|
|
138
161
|
// No key is needed once registered: the leader verifies our bearer token.
|
|
139
|
-
export async function refreshGroupMembers(name, { leaderUrl, memberName, url, token, fetchImpl = fetch } = {}) {
|
|
162
|
+
export async function refreshGroupMembers(name, { leaderUrl, memberName, url, token, kind, fetchImpl = fetch } = {}) {
|
|
140
163
|
const controller = new AbortController();
|
|
141
164
|
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS);
|
|
142
165
|
try {
|
|
166
|
+
const body = { name, memberName, url, token };
|
|
167
|
+
if (kind) body.kind = kind;
|
|
143
168
|
const res = await fetchImpl(`${leaderUrl}/v1/groups/join`, {
|
|
144
169
|
method: "POST",
|
|
145
170
|
headers: {
|
|
146
171
|
"Content-Type": "application/json",
|
|
147
172
|
"Authorization": `Bearer ${token}`,
|
|
148
173
|
},
|
|
149
|
-
body: JSON.stringify(
|
|
174
|
+
body: JSON.stringify(body),
|
|
150
175
|
signal: controller.signal,
|
|
151
176
|
});
|
|
152
177
|
if (!res.ok) {
|
|
@@ -163,6 +188,8 @@ export async function refreshGroupMembers(name, { leaderUrl, memberName, url, to
|
|
|
163
188
|
// Merge a member map into the local peer list, replacing the previous snapshot
|
|
164
189
|
// of this group and skipping ourselves. Used by both leaders (local groups) and
|
|
165
190
|
// members (leader-pulled members).
|
|
191
|
+
// broadband members (relay://) are NOT added as direct peers — they are
|
|
192
|
+
// reached via the leader relay, so we skip them here.
|
|
166
193
|
export function syncPeersFromMembers({ peers, members, myUrl, group, skipIds = [] }) {
|
|
167
194
|
const self = normalizePeerUrl(myUrl);
|
|
168
195
|
const skip = new Set(skipIds);
|
|
@@ -170,6 +197,8 @@ export function syncPeersFromMembers({ peers, members, myUrl, group, skipIds = [
|
|
|
170
197
|
let added = 0;
|
|
171
198
|
for (const [id, m] of Object.entries(members || {})) {
|
|
172
199
|
if (skip.has(id)) continue;
|
|
200
|
+
const kind = m?.kind || "static";
|
|
201
|
+
if (kind === "broadband" || String(m?.url || "").startsWith("relay://")) continue;
|
|
173
202
|
const url = normalizePeerUrl(m?.url);
|
|
174
203
|
if (!url || url === self) continue;
|
|
175
204
|
if (peers.add({ name: id, url, token: m?.token || "", group })) added++;
|
package/src/peers.js
CHANGED
|
@@ -3,9 +3,23 @@ import { loadPeers, savePeers, loadPeerErrors, savePeerErrors, loadPeerStats, sa
|
|
|
3
3
|
export const DEFAULT_PEER_COOLDOWN_MS = 30_000;
|
|
4
4
|
export const DEFAULT_PEER_HEAT_MS = 5 * 60_000;
|
|
5
5
|
export const DEFAULT_MAX_HOPS = 3;
|
|
6
|
+
export const DEFAULT_BROADBAND_STALE_MS = 90_000;
|
|
6
7
|
|
|
7
8
|
const EMA_ALPHA = 0.3;
|
|
8
9
|
|
|
10
|
+
export function broadbandStaleMs() {
|
|
11
|
+
const n = Number(process.env.MSLXDFF_BROADBAND_STALE_MS);
|
|
12
|
+
return Number.isInteger(n) && n > 0 ? n : DEFAULT_BROADBAND_STALE_MS;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isBroadbandStale(peer, nowMs = Date.now(), staleMs = broadbandStaleMs()) {
|
|
16
|
+
const kind = peer?.kind || (String(peer?.url || "").startsWith("relay://") ? "broadband" : "static");
|
|
17
|
+
if (kind !== "broadband") return false;
|
|
18
|
+
const lastSeen = peer?.lastSeen;
|
|
19
|
+
if (typeof lastSeen !== "number") return true; // no heartbeat yet => stale
|
|
20
|
+
return nowMs - lastSeen > staleMs;
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
export function normalizePeerUrl(url) {
|
|
10
24
|
return String(url || "").trim().replace(/\/+$/, "");
|
|
11
25
|
}
|
|
@@ -64,7 +78,16 @@ export function createPeersService({
|
|
|
64
78
|
function isCooling(url) {
|
|
65
79
|
if (!cooldownMs) return false;
|
|
66
80
|
const err = lastErrorAt[url];
|
|
67
|
-
|
|
81
|
+
if (typeof err === "number" && now() - err < cooldownMs) return true;
|
|
82
|
+
const peer = list.find((p) => p.url === url);
|
|
83
|
+
if (peer && isBroadbandStale(peer, now(), broadbandStaleMs())) return true;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isBroadbandCooling(url) {
|
|
88
|
+
const peer = list.find((p) => p.url === url);
|
|
89
|
+
if (!peer) return false;
|
|
90
|
+
return isBroadbandStale(peer, now(), broadbandStaleMs());
|
|
68
91
|
}
|
|
69
92
|
|
|
70
93
|
function available() {
|
|
@@ -168,7 +191,10 @@ export function createPeersService({
|
|
|
168
191
|
}
|
|
169
192
|
|
|
170
193
|
return {
|
|
171
|
-
all, add, remove, removeByGroup, isCooling,
|
|
194
|
+
all, add, remove, removeByGroup, isCooling, isBroadbandCooling, isBroadbandStale: (url) => {
|
|
195
|
+
const peer = list.find((p) => p.url === url);
|
|
196
|
+
return peer ? isBroadbandStale(peer, now(), broadbandStaleMs()) : false;
|
|
197
|
+
}, isHot, stat, ordered, orderedByLastError, available, next,
|
|
172
198
|
recordError, recordResult, errors: () => ({ ...lastErrorAt }), stats: () => ({ ...stats }),
|
|
173
199
|
};
|
|
174
200
|
}
|
package/src/routes.js
CHANGED
|
@@ -3,9 +3,152 @@ import { performance } from "node:perf_hooks";
|
|
|
3
3
|
import { injectReasoningContent, normalizeModel } from "./reasoning.js";
|
|
4
4
|
import { isAutoModel } from "./auto.js";
|
|
5
5
|
import { DEFAULT_MAX_HOPS } from "./peers.js";
|
|
6
|
+
import { loadGroupsJoined } from "./state.js";
|
|
6
7
|
|
|
7
8
|
export const errMsg = (err) => String(err?.message || err);
|
|
8
9
|
|
|
10
|
+
// In-memory relay queues for broadband members (polling-based, no WS dependency)
|
|
11
|
+
// pendingByTarget: Map<`${group}::${targetUrl}`, Array<{reqId, body, hops, resolve, reject, timer}>>
|
|
12
|
+
const relayPending = new Map();
|
|
13
|
+
const relayPendingByReqId = new Map();
|
|
14
|
+
|
|
15
|
+
function enqueueRelay({ group, target, reqId, body, hops }) {
|
|
16
|
+
const key = `${group}::${target}`;
|
|
17
|
+
const list = relayPending.get(key) || [];
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const timer = setTimeout(() => {
|
|
20
|
+
const idx = list.findIndex((e) => e.reqId === reqId);
|
|
21
|
+
if (idx >= 0) list.splice(idx, 1);
|
|
22
|
+
relayPendingByReqId.delete(reqId);
|
|
23
|
+
reject(new Error("relay timeout"));
|
|
24
|
+
}, 30_000);
|
|
25
|
+
timer.unref?.();
|
|
26
|
+
const entry = { reqId, body, hops, resolve, reject, timer };
|
|
27
|
+
list.push(entry);
|
|
28
|
+
relayPending.set(key, list);
|
|
29
|
+
relayPendingByReqId.set(reqId, entry);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function dequeueRelayForPoll({ group, target, limit = 10 }) {
|
|
34
|
+
const key = `${group}::${target}`;
|
|
35
|
+
const list = relayPending.get(key) || [];
|
|
36
|
+
const batch = list.splice(0, limit);
|
|
37
|
+
for (const e of batch) {
|
|
38
|
+
// keep timer, but remove from pending list; result will resolve via relayResult
|
|
39
|
+
// we keep entry in relayPendingByReqId until result arrives
|
|
40
|
+
}
|
|
41
|
+
if (list.length) relayPending.set(key, list);
|
|
42
|
+
else relayPending.delete(key);
|
|
43
|
+
return batch.map((e) => ({ reqId: e.reqId, body: e.body, hops: e.hops }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveRelay(reqId, result) {
|
|
47
|
+
const entry = relayPendingByReqId.get(reqId);
|
|
48
|
+
if (!entry) return false;
|
|
49
|
+
clearTimeout(entry.timer);
|
|
50
|
+
relayPendingByReqId.delete(reqId);
|
|
51
|
+
entry.resolve(result);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function tryBroadbandRelay({ groups, token: myToken, model, body, hops, bus, logs, reqId, evt, res, mark, perf0, stages }) {
|
|
56
|
+
try {
|
|
57
|
+
const joined = loadGroupsJoined();
|
|
58
|
+
const broadbandGroups = joined.filter((g) => g.kind === "broadband" || g.myUrl?.startsWith("relay://"));
|
|
59
|
+
// Also consider leader-owned broadband members directly
|
|
60
|
+
const allCandidates = [];
|
|
61
|
+
// 1) If this node is leader, check its own groups for broadband members
|
|
62
|
+
if (groups) {
|
|
63
|
+
const localGroups = groups.list();
|
|
64
|
+
for (const [gName, g] of Object.entries(localGroups)) {
|
|
65
|
+
for (const [id, m] of Object.entries(g.members || {})) {
|
|
66
|
+
if (id === "leader") continue;
|
|
67
|
+
const isBb = m?.kind === "broadband" || String(m?.url || "").startsWith("relay://");
|
|
68
|
+
if (!isBb) continue;
|
|
69
|
+
const staleMs = Number(process.env.MSLXDFF_BROADBAND_STALE_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_STALE_MS) : 90_000;
|
|
70
|
+
if (typeof m.lastSeen === "number" && Date.now() - m.lastSeen > staleMs) continue;
|
|
71
|
+
// local leader: enqueue directly
|
|
72
|
+
allCandidates.push({ group: gName, target: m.url, member: m, via: "local-leader", leaderUrl: null });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// 2) For member nodes, check leader's broadband members (need leaderUrl)
|
|
77
|
+
for (const g of joined) {
|
|
78
|
+
if (!g.leaderUrl) continue; // already handled as leader
|
|
79
|
+
try {
|
|
80
|
+
// fetch fresh members from leader (re-use refreshGroupMembers logic but direct fetch)
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
83
|
+
const r = await fetch(`${g.leaderUrl}/v1/groups/join`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${myToken}` },
|
|
86
|
+
body: JSON.stringify({ name: g.name, memberName: g.memberName, url: g.myUrl, token: myToken }),
|
|
87
|
+
signal: controller.signal,
|
|
88
|
+
});
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
if (!r.ok) continue;
|
|
91
|
+
const data = await r.json().catch(() => ({}));
|
|
92
|
+
const members = data.members || {};
|
|
93
|
+
for (const [id, m] of Object.entries(members)) {
|
|
94
|
+
if (id === "leader") continue;
|
|
95
|
+
const isBb = m?.kind === "broadband" || String(m?.url || "").startsWith("relay://");
|
|
96
|
+
if (!isBb) continue;
|
|
97
|
+
if (m.url === g.myUrl) continue; // skip self
|
|
98
|
+
const staleMs = Number(process.env.MSLXDFF_BROADBAND_STALE_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_STALE_MS) : 90_000;
|
|
99
|
+
if (typeof m.lastSeen === "number" && Date.now() - m.lastSeen > staleMs) continue;
|
|
100
|
+
allCandidates.push({ group: g.name, target: m.url, member: m, via: "via-leader", leaderUrl: g.leaderUrl });
|
|
101
|
+
}
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
if (!allCandidates.length) return null;
|
|
105
|
+
// Try each candidate via relay forward (sequential, first success wins)
|
|
106
|
+
for (const cand of allCandidates) {
|
|
107
|
+
try {
|
|
108
|
+
evt?.("relay-try", { reqId, model, via: cand.via, target: cand.target, group: cand.group });
|
|
109
|
+
if (!cand.leaderUrl) {
|
|
110
|
+
// local leader: enqueue and wait for broadband poll (same as forward handler's local path)
|
|
111
|
+
const fwdBody = { model, ...body, model };
|
|
112
|
+
const reqIdLocal = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
113
|
+
const promise = enqueueRelay({ group: cand.group, target: cand.target, reqId: reqIdLocal, body: fwdBody, hops });
|
|
114
|
+
// also trigger a dummy poll wait: we can't directly wait for poll without D, so timeout quickly
|
|
115
|
+
// For local leader, the D will poll; we wait for result with timeout 30s
|
|
116
|
+
const result = await promise;
|
|
117
|
+
if (result && result.status) {
|
|
118
|
+
return { via: "broadband-local", result, target: cand.target, group: cand.group };
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
// forward via leader's relay/forward endpoint
|
|
122
|
+
const ctrl = new AbortController();
|
|
123
|
+
const t = setTimeout(() => ctrl.abort(), 35_000);
|
|
124
|
+
const r = await fetch(`${cand.leaderUrl}/v1/groups/relay/forward`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${myToken}`, "x-mslxdff-hops": String(hops + 1) },
|
|
127
|
+
body: JSON.stringify({ group: cand.group, target: cand.target, body: { ...body, model }, hops: hops + 1, reqId }),
|
|
128
|
+
signal: ctrl.signal,
|
|
129
|
+
});
|
|
130
|
+
clearTimeout(t);
|
|
131
|
+
if (!r.ok) {
|
|
132
|
+
const txt = await r.text().catch(() => "");
|
|
133
|
+
evt?.("relay-fail", { reqId, model, via: cand.via, target: cand.target, status: r.status, message: txt.slice(0,200) });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
// leader's forward returns the upstream response (maybe SSE)
|
|
137
|
+
// For simplicity, we treat it as direct response to relay back to client
|
|
138
|
+
// We can stream it back: but for now, we just return the Response
|
|
139
|
+
return { via: "broadband-via-leader", result: r, target: cand.target, group: cand.group };
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
evt?.("relay-fail", { reqId, model, via: cand.via, target: cand.target, message: String(err?.message || err).slice(0,200) });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
9
152
|
export function createRouter({ token, upstream, models, auto, logs, peers, maxHops = DEFAULT_MAX_HOPS, groups, bans, bus }) {
|
|
10
153
|
return async function router(req, res) {
|
|
11
154
|
const method = req.method || "GET";
|
|
@@ -419,7 +562,7 @@ const ROUTES = [
|
|
|
419
562
|
method: "POST",
|
|
420
563
|
path: "/v1/chat/completions",
|
|
421
564
|
requiresAuth: true,
|
|
422
|
-
handler: async ({ req, res, upstream, auto, logs, peers, maxHops, bus }) => {
|
|
565
|
+
handler: async ({ req, res, upstream, auto, logs, peers, maxHops, groups, bus }) => {
|
|
423
566
|
let body;
|
|
424
567
|
try {
|
|
425
568
|
body = await readBody(req);
|
|
@@ -596,6 +739,59 @@ const ROUTES = [
|
|
|
596
739
|
evt("peer-race-lose", { reqId, model });
|
|
597
740
|
}
|
|
598
741
|
|
|
742
|
+
// Broadband relay: try to forward via leader to a broadband member's quota
|
|
743
|
+
if (groups) {
|
|
744
|
+
const bb = await tryBroadbandRelay({ groups, token, model, body, hops, bus, logs, reqId, evt, res, mark, perf0, stages });
|
|
745
|
+
if (bb) {
|
|
746
|
+
const isResponse = bb.result && typeof bb.result.status === "number" && typeof bb.result.headers?.get === "function";
|
|
747
|
+
if (isResponse) {
|
|
748
|
+
// streaming response from leader's forward (which waited for broadband)
|
|
749
|
+
evt("relay-start", { reqId, model, via: "broadband", target: bb.target, group: bb.group });
|
|
750
|
+
const out = await relay(res, bb.result, body, {
|
|
751
|
+
onFirstChunk: (d) => mark(`ttf-bb-${model}`),
|
|
752
|
+
onDownstreamAbort: () => evt("client-abort", { reqId, model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
753
|
+
});
|
|
754
|
+
evt("relay-done", { reqId, model, via: "broadband", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
755
|
+
if (auto && out.status === 200) {
|
|
756
|
+
const latencyMs = out.totalMs ?? 0;
|
|
757
|
+
if (out.detail?.stallHits > 0 || (latencyMs && latencyMs > SLOW_TOTAL_MS)) {
|
|
758
|
+
void auto.recordError(model, { status: 200, slow: true, note: `broadband slow ${latencyMs}ms` });
|
|
759
|
+
void auto.recordLatency(model, latencyMs);
|
|
760
|
+
} else {
|
|
761
|
+
await auto.recordOk(model, { latencyMs });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
evt("result", { model, status: out.status, via: "broadband", timing: bb.result._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
765
|
+
return;
|
|
766
|
+
} else if (bb.result && typeof bb.result.status === "number") {
|
|
767
|
+
// buffered result from local leader enqueue
|
|
768
|
+
const fakeRes = {
|
|
769
|
+
status: bb.result.status,
|
|
770
|
+
headers: { get: (k) => bb.result.headers?.[k] || bb.result.headers?.[k.toLowerCase()] || null },
|
|
771
|
+
text: async () => typeof bb.result.body === "string" ? bb.result.body : JSON.stringify(bb.result.body),
|
|
772
|
+
body: (() => {
|
|
773
|
+
const b = bb.result.body || "";
|
|
774
|
+
const str = typeof b === "string" ? b : JSON.stringify(b);
|
|
775
|
+
const isSSE = bb.result.headers?.["Content-Type"]?.includes("text/event-stream");
|
|
776
|
+
if (isSSE) {
|
|
777
|
+
return (async function* () { yield Buffer.from(str); })();
|
|
778
|
+
}
|
|
779
|
+
return null;
|
|
780
|
+
})(),
|
|
781
|
+
};
|
|
782
|
+
evt("relay-start", { reqId, model, via: "broadband-local", target: bb.target, group: bb.group });
|
|
783
|
+
const out = await relay(res, fakeRes, body, {
|
|
784
|
+
onFirstChunk: (d) => mark(`ttf-bb-${model}`),
|
|
785
|
+
onDownstreamAbort: () => evt("client-abort", { reqId, model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
786
|
+
});
|
|
787
|
+
evt("relay-done", { reqId, model, via: "broadband-local", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
788
|
+
evt("result", { model, status: out.status, via: "broadband", timing: null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
evt("relay-miss", { reqId, model });
|
|
793
|
+
}
|
|
794
|
+
|
|
599
795
|
if (canFallback) {
|
|
600
796
|
evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: lastErr?.message || `upstream ${lastErr?.status ?? 502}` });
|
|
601
797
|
continue;
|
|
@@ -670,11 +866,31 @@ const ROUTES = [
|
|
|
670
866
|
const hit = auth && groups.membersForToken(body.name, auth[1]);
|
|
671
867
|
if (!hit) return fail("invalid member token");
|
|
672
868
|
try {
|
|
869
|
+
const isBroadbandRe = String(body.url || hit.member?.url || "").startsWith("relay://") || body.kind === "broadband" || hit.member?.kind === "broadband";
|
|
870
|
+
const extra = {};
|
|
871
|
+
if (isBroadbandRe) {
|
|
872
|
+
extra.kind = "broadband";
|
|
873
|
+
extra.publicIp = ip;
|
|
874
|
+
extra.lastSeen = Date.now();
|
|
875
|
+
if (body.url) extra.url = String(body.url);
|
|
876
|
+
}
|
|
673
877
|
const refreshed = groups.upsertMember(body.name, {
|
|
674
878
|
memberName: body.memberName,
|
|
675
|
-
url: body.url,
|
|
676
|
-
token: body.token,
|
|
879
|
+
url: body.url || hit.member.url,
|
|
880
|
+
token: body.token || hit.member.token,
|
|
881
|
+
kind: extra.kind,
|
|
882
|
+
publicIp: extra.publicIp,
|
|
883
|
+
lastSeen: extra.lastSeen,
|
|
677
884
|
});
|
|
885
|
+
// also ensure broadband's publicIp/lastSeen updated even if url same
|
|
886
|
+
if (isBroadbandRe && refreshed) {
|
|
887
|
+
const targetId = Object.keys(refreshed).find((k) => refreshed[k].url === (body.url || hit.member.url));
|
|
888
|
+
if (targetId) {
|
|
889
|
+
refreshed[targetId].publicIp = ip;
|
|
890
|
+
refreshed[targetId].lastSeen = Date.now();
|
|
891
|
+
refreshed[targetId].kind = "broadband";
|
|
892
|
+
}
|
|
893
|
+
}
|
|
678
894
|
return json(res, 200, { object: "group", name: body.name, members: refreshed });
|
|
679
895
|
} catch (err) {
|
|
680
896
|
return json(res, 400, { error: errMsg(err) });
|
|
@@ -684,13 +900,21 @@ const ROUTES = [
|
|
|
684
900
|
try {
|
|
685
901
|
const youPort = Number(body.myPort);
|
|
686
902
|
const youUrl = Number.isInteger(youPort) && youPort > 0 ? `http://${ip}:${youPort}` : "";
|
|
687
|
-
|
|
903
|
+
let memberUrl = String(body.url || youUrl);
|
|
688
904
|
if (!memberUrl) throw new Error("member url is required");
|
|
905
|
+
const isBroadband = String(memberUrl).startsWith("relay://") || body.kind === "broadband";
|
|
906
|
+
if (isBroadband) {
|
|
907
|
+
// broadband: keep relay:// url, record publicIp from source ip
|
|
908
|
+
memberUrl = String(body.url || memberUrl);
|
|
909
|
+
}
|
|
689
910
|
const members = groups.addMember(body.name, {
|
|
690
911
|
key: body.key,
|
|
691
912
|
memberName: body.memberName,
|
|
692
913
|
url: memberUrl,
|
|
693
914
|
token: body.token,
|
|
915
|
+
kind: isBroadband ? "broadband" : "static",
|
|
916
|
+
publicIp: isBroadband ? ip : undefined,
|
|
917
|
+
lastSeen: isBroadband ? Date.now() : undefined,
|
|
694
918
|
});
|
|
695
919
|
// 5 wrong passwords bans the source IP (48h) — see createBansService.
|
|
696
920
|
if (bans) bans.clear(ip);
|
|
@@ -743,6 +967,156 @@ const ROUTES = [
|
|
|
743
967
|
}
|
|
744
968
|
},
|
|
745
969
|
},
|
|
970
|
+
{
|
|
971
|
+
method: "POST",
|
|
972
|
+
path: "/v1/groups/relay/heartbeat",
|
|
973
|
+
requiresAuth: false,
|
|
974
|
+
handler: async ({ req, res, groups, bus, logs }) => {
|
|
975
|
+
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
976
|
+
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
977
|
+
let body;
|
|
978
|
+
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
979
|
+
const groupName = body?.name || body?.group;
|
|
980
|
+
if (!groupName) return json(res, 400, { error: "group name is required" });
|
|
981
|
+
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
982
|
+
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
983
|
+
const ip = clientIp(req);
|
|
984
|
+
try {
|
|
985
|
+
const memberUrl = hit.member?.url;
|
|
986
|
+
const members = groups.list()[groupName]?.members || {};
|
|
987
|
+
const targetId = Object.keys(members).find((k) => members[k].url === memberUrl) || hit.member?.url;
|
|
988
|
+
// update lastSeen/publicIp for broadband
|
|
989
|
+
if (hit.member?.kind === "broadband" || String(memberUrl).startsWith("relay://")) {
|
|
990
|
+
const m = members[targetId] || hit.member;
|
|
991
|
+
if (m) {
|
|
992
|
+
m.publicIp = ip;
|
|
993
|
+
m.lastSeen = Date.now();
|
|
994
|
+
// persist via groups service (upsert)
|
|
995
|
+
try { groups.upsertMember(groupName, { memberName: targetId, url: m.url, token: m.token, kind: "broadband", publicIp: ip, lastSeen: m.lastSeen }); } catch {}
|
|
996
|
+
}
|
|
997
|
+
const evtData = { ts: Date.now(), type: "relay-heartbeat", member: targetId, ip, lastSeen: m?.lastSeen, group: groupName };
|
|
998
|
+
if (bus) bus.emit(evtData);
|
|
999
|
+
logs?.appendEvent?.(evtData);
|
|
1000
|
+
}
|
|
1001
|
+
return json(res, 200, { object: "heartbeat", ok: true, ip, lastSeen: Date.now() });
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
return json(res, 400, { error: errMsg(err) });
|
|
1004
|
+
}
|
|
1005
|
+
},
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
method: "POST",
|
|
1009
|
+
path: "/v1/groups/relay/poll",
|
|
1010
|
+
requiresAuth: false,
|
|
1011
|
+
handler: async ({ req, res, groups }) => {
|
|
1012
|
+
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
1013
|
+
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
1014
|
+
let body;
|
|
1015
|
+
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
1016
|
+
const groupName = body?.name || body?.group;
|
|
1017
|
+
if (!groupName) return json(res, 400, { error: "group name is required" });
|
|
1018
|
+
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
1019
|
+
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
1020
|
+
const targetUrl = hit.member?.url;
|
|
1021
|
+
if (!targetUrl) return json(res, 400, { error: "member url not found" });
|
|
1022
|
+
const batch = dequeueRelayForPoll({ group: groupName, target: targetUrl, limit: 10 });
|
|
1023
|
+
return json(res, 200, { object: "poll", data: batch });
|
|
1024
|
+
},
|
|
1025
|
+
},
|
|
1026
|
+
{
|
|
1027
|
+
method: "POST",
|
|
1028
|
+
path: "/v1/groups/relay/result",
|
|
1029
|
+
requiresAuth: false,
|
|
1030
|
+
handler: async ({ req, res, groups }) => {
|
|
1031
|
+
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
1032
|
+
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
1033
|
+
let body;
|
|
1034
|
+
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
1035
|
+
const groupName = body?.name || body?.group;
|
|
1036
|
+
const reqId = body?.reqId;
|
|
1037
|
+
if (!groupName || !reqId) return json(res, 400, { error: "group and reqId required" });
|
|
1038
|
+
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
1039
|
+
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
1040
|
+
const ok = resolveRelay(reqId, body.result || body);
|
|
1041
|
+
if (!ok) return json(res, 404, { error: "pending request not found or timed out" });
|
|
1042
|
+
return json(res, 200, { object: "result", ok: true });
|
|
1043
|
+
},
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
method: "POST",
|
|
1047
|
+
path: "/v1/groups/relay/forward",
|
|
1048
|
+
requiresAuth: true,
|
|
1049
|
+
handler: async ({ req, res, groups, bus, logs }) => {
|
|
1050
|
+
let body;
|
|
1051
|
+
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
1052
|
+
const groupName = body?.group || body?.name;
|
|
1053
|
+
const target = body?.target || body?.url;
|
|
1054
|
+
const hops = parseHops(req.headers["x-mslxdff-hops"] || body?.hops);
|
|
1055
|
+
if (!groupName || !target) return json(res, 400, { error: "group and target required" });
|
|
1056
|
+
if (hops >= DEFAULT_MAX_HOPS) return json(res, 429, { error: "max hops exceeded" });
|
|
1057
|
+
const members = groups?.list()[groupName]?.members || {};
|
|
1058
|
+
const targetMember = Object.values(members).find((m) => m.url === target) || Object.entries(members).find(([id]) => id === target)?.[1];
|
|
1059
|
+
if (!targetMember) return json(res, 404, { error: `target ${target} not found in group ${groupName}` });
|
|
1060
|
+
const isBb = targetMember.kind === "broadband" || String(targetMember.url).startsWith("relay://");
|
|
1061
|
+
if (!isBb) {
|
|
1062
|
+
// static: direct fetch (should have been handled by peer race, but support via relay as well)
|
|
1063
|
+
try {
|
|
1064
|
+
const fwdBody = body.body || body;
|
|
1065
|
+
const r = await fetch(`${targetMember.url}/v1/chat/completions`, {
|
|
1066
|
+
method: "POST",
|
|
1067
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${targetMember.token || ""}`, "x-mslxdff-hops": String(hops + 1), "x-mslxdff-model-lock": fwdBody.model || "", "Accept": "text/event-stream" },
|
|
1068
|
+
body: JSON.stringify(fwdBody),
|
|
1069
|
+
});
|
|
1070
|
+
const evtData = { ts: Date.now(), type: "relay-forward", target, via: "direct", group: groupName, hops };
|
|
1071
|
+
if (bus) bus.emit(evtData);
|
|
1072
|
+
logs?.appendEvent?.(evtData);
|
|
1073
|
+
res.statusCode = r.status;
|
|
1074
|
+
if (r.headers.get("content-type")?.includes("text/event-stream")) {
|
|
1075
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
1076
|
+
if (r.body) for await (const c of r.body) res.write(c);
|
|
1077
|
+
res.end();
|
|
1078
|
+
} else {
|
|
1079
|
+
const txt = await r.text();
|
|
1080
|
+
res.setHeader("Content-Type", r.headers.get("content-type") || "application/json");
|
|
1081
|
+
res.end(txt);
|
|
1082
|
+
}
|
|
1083
|
+
return;
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
return json(res, 502, { error: errMsg(err) });
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
// broadband: enqueue and wait for poll/result
|
|
1089
|
+
const reqId = body.reqId || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
1090
|
+
const fwdBody = body.body || { model: body.model, messages: body.messages, stream: body.stream };
|
|
1091
|
+
const evtData = { ts: Date.now(), type: "relay-forward", target, via: "leader", group: groupName, hops, reqId, model: fwdBody.model };
|
|
1092
|
+
if (bus) bus.emit(evtData);
|
|
1093
|
+
logs?.appendEvent?.(evtData);
|
|
1094
|
+
// check stale
|
|
1095
|
+
const staleMs = Number(process.env.MSLXDFF_BROADBAND_STALE_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_STALE_MS) : 90_000;
|
|
1096
|
+
if (typeof targetMember.lastSeen === "number" && Date.now() - targetMember.lastSeen > staleMs) {
|
|
1097
|
+
return json(res, 502, { error: "broadband member stale (no heartbeat)" });
|
|
1098
|
+
}
|
|
1099
|
+
try {
|
|
1100
|
+
const resultPromise = enqueueRelay({ group: groupName, target: targetMember.url, reqId, body: fwdBody, hops });
|
|
1101
|
+
// race with timeout already in enqueueRelay (30s)
|
|
1102
|
+
const result = await resultPromise;
|
|
1103
|
+
// result is expected to be { status, headers, body } from broadband
|
|
1104
|
+
if (result && typeof result.status === "number") {
|
|
1105
|
+
res.statusCode = result.status;
|
|
1106
|
+
if (result.headers) for (const [k, v] of Object.entries(result.headers)) res.setHeader(k, v);
|
|
1107
|
+
if (result.body) {
|
|
1108
|
+
if (typeof result.body === "string") res.end(result.body);
|
|
1109
|
+
else res.end(JSON.stringify(result.body));
|
|
1110
|
+
} else res.end();
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
// if result is raw upstream response body
|
|
1114
|
+
return json(res, 200, result);
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
return json(res, 504, { error: errMsg(err) || "relay timeout" });
|
|
1117
|
+
}
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
746
1120
|
{
|
|
747
1121
|
method: "GET",
|
|
748
1122
|
path: "/v1/models",
|
package/src/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createServer as httpCreateServer } from "node:http";
|
|
2
2
|
import { DEFAULT_PORT, getPort } from "./state.js";
|
|
3
3
|
|
|
4
|
-
export function startServer({ router, signals = true }, port = resolvePort()) {
|
|
4
|
+
export function startServer({ router, signals = true, host }, port = resolvePort()) {
|
|
5
|
+
const listenHost = host ?? resolveHost();
|
|
5
6
|
const server = httpCreateServer((req, res) => {
|
|
6
7
|
router(req, res).catch((err) => {
|
|
7
8
|
res.statusCode = 500;
|
|
@@ -13,7 +14,8 @@ export function startServer({ router, signals = true }, port = resolvePort()) {
|
|
|
13
14
|
const ready = () =>
|
|
14
15
|
new Promise((resolve, reject) => {
|
|
15
16
|
server.on("error", reject);
|
|
16
|
-
server.listen(port, resolve);
|
|
17
|
+
if (listenHost) server.listen(port, listenHost, resolve);
|
|
18
|
+
else server.listen(port, resolve);
|
|
17
19
|
});
|
|
18
20
|
|
|
19
21
|
const close = () =>
|
|
@@ -32,6 +34,12 @@ export function startServer({ router, signals = true }, port = resolvePort()) {
|
|
|
32
34
|
return { server, ready, close };
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
export function resolveHost() {
|
|
38
|
+
const envHost = process.env.MSLXDFF_HOST || process.env.MSLXDFF_BIND_HOST;
|
|
39
|
+
if (typeof envHost === "string" && envHost.trim()) return envHost.trim();
|
|
40
|
+
return "0.0.0.0";
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
export function resolvePort() {
|
|
36
44
|
// 8989 is the hard default. Port only changes via an explicit, mslxdff-owned
|
|
37
45
|
// override: the persisted `-port N` setting, or the MSLXDFF_PORT env var.
|