mslxdff 0.1.33 → 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 +154 -14
- 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
|
@@ -280,14 +280,31 @@ if (groupCmd && groupCmd !== "create") {
|
|
|
280
280
|
console.log(`${g.name} (${entries.length} member${entries.length === 1 ? "" : "s"})`);
|
|
281
281
|
// probe every member endpoint concurrently for reachability + latency;
|
|
282
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
|
|
283
284
|
const probes = await Promise.all(
|
|
284
|
-
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 }))
|
|
285
286
|
);
|
|
286
287
|
const leaderEntry = members.leader ? Object.entries(members).find(([id]) => id === "leader") : null;
|
|
287
288
|
const leaderProbe = leaderEntry ? await probeHealth({ id: "leader", url: leaderEntry[1].url }) : null;
|
|
288
289
|
const display = leaderProbe ? [...probes, leaderProbe] : probes;
|
|
289
290
|
let seq = 0;
|
|
290
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
|
+
}
|
|
291
308
|
const state = r.fail ? `fail ${r.fail}` : `ok ${r.ms}ms`;
|
|
292
309
|
if (r.id === "leader") {
|
|
293
310
|
console.log(` leader ${r.url} ${state}`);
|
|
@@ -334,12 +351,15 @@ if (groupCmd && groupCmd !== "create") {
|
|
|
334
351
|
process.exit(0);
|
|
335
352
|
}
|
|
336
353
|
|
|
337
|
-
// -addtogroup <leader-host> <name>
|
|
354
|
+
// -addtogroup <leader-host> <name> [--broadband]
|
|
338
355
|
const addToGroupIdx = args.findIndex((x) => x === "-addtogroup" || x === "--addtogroup");
|
|
339
356
|
if (addToGroupIdx >= 0) {
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
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]");
|
|
343
363
|
process.exit(1);
|
|
344
364
|
}
|
|
345
365
|
const groups = createGroupsService({});
|
|
@@ -348,25 +368,34 @@ if (addToGroupIdx >= 0) {
|
|
|
348
368
|
const leaderUrl = leaderHost.includes("://")
|
|
349
369
|
? leaderHost.replace(/\/+$/, "")
|
|
350
370
|
: `http://${leaderHost}${leaderHost.includes(":") ? "" : ":8989"}`;
|
|
351
|
-
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
|
+
}
|
|
352
380
|
try {
|
|
353
381
|
const res = await fetch(`${leaderUrl}/v1/groups/join`, {
|
|
354
382
|
method: "POST",
|
|
355
383
|
headers: { "Content-Type": "application/json" },
|
|
356
|
-
body: JSON.stringify(
|
|
384
|
+
body: JSON.stringify(joinBody),
|
|
357
385
|
});
|
|
358
386
|
if (!res.ok) {
|
|
359
387
|
const text = await res.text().catch(() => "");
|
|
360
388
|
throw new Error(`join failed (HTTP ${res.status}): ${text}`);
|
|
361
389
|
}
|
|
362
390
|
const data = await res.json();
|
|
363
|
-
const myUrl = data.you?.url || "";
|
|
364
|
-
|
|
391
|
+
const myUrl = data.you?.url || joinBody.url || "";
|
|
392
|
+
const memberName = isBroadband ? myUrl : myUrl;
|
|
393
|
+
markJoined({ name, leaderUrl, myUrl, memberName, kind });
|
|
365
394
|
const synced = await syncAllJoinedGroups({ peers, groups });
|
|
366
395
|
const s = synced.find((x) => x.name === name);
|
|
367
|
-
console.log(`joined group "${name}" at ${leaderUrl}`);
|
|
396
|
+
console.log(`joined group "${name}" at ${leaderUrl}${isBroadband ? " [broadband]" : ""}`);
|
|
368
397
|
if (s?.error) console.log(` local failover setup failed: ${s.error}`);
|
|
369
|
-
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)" : ""}`);
|
|
370
399
|
} catch (err) {
|
|
371
400
|
console.error(`join failed: ${err.message}`);
|
|
372
401
|
process.exit(1);
|
|
@@ -386,6 +415,7 @@ if (resetBanArg !== null || args.includes("-resetban") || args.includes("--reset
|
|
|
386
415
|
|
|
387
416
|
function markJoined(entry) {
|
|
388
417
|
const { name } = entry;
|
|
418
|
+
if (!entry.kind) entry.kind = "static";
|
|
389
419
|
const list = loadGroupsJoined().filter((g) => g.name !== name);
|
|
390
420
|
saveGroupsJoined([...list, entry]);
|
|
391
421
|
}
|
|
@@ -464,8 +494,15 @@ function errMsg(err) { return String(err?.message || err); }
|
|
|
464
494
|
|
|
465
495
|
// Probe a member's health endpoint concurrently (v1/health preferred,
|
|
466
496
|
// falling back to /health). Returns { id, url, ms, rank } or { id, url, fail }.
|
|
467
|
-
|
|
497
|
+
// broadband relay:// members are not probed — they are reachable via leader relay.
|
|
498
|
+
async function probeHealth({ id, url, kind, lastSeen, publicIp } = {}) {
|
|
468
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
|
+
}
|
|
469
506
|
const base = String(url).replace(/\/+$/, "");
|
|
470
507
|
const startedAt = Date.now();
|
|
471
508
|
try {
|
|
@@ -500,6 +537,7 @@ async function syncAllJoinedGroups({ peers, groups }) {
|
|
|
500
537
|
memberName: g.memberName,
|
|
501
538
|
url: g.myUrl,
|
|
502
539
|
token: myToken,
|
|
540
|
+
kind: g.kind,
|
|
503
541
|
});
|
|
504
542
|
results.push({
|
|
505
543
|
name: g.name,
|
|
@@ -626,7 +664,8 @@ const bans = createBansService({ windowMs: banWindowMs(), threshold: banThreshol
|
|
|
626
664
|
const isDebug = process.env.MSLXDFF_DEBUG === "1";
|
|
627
665
|
const bus = createEventBus();
|
|
628
666
|
const router = createRouter({ token, upstream, models, auto, logs, peers, maxHops: maxHopsValue(), groups, bans, bus });
|
|
629
|
-
const
|
|
667
|
+
const listenHost = effectiveHost();
|
|
668
|
+
const srv = startServer({ router, signals: !isDebug, host: listenHost });
|
|
630
669
|
|
|
631
670
|
// -debug: push every event straight to this terminal.
|
|
632
671
|
if (isDebug) {
|
|
@@ -687,6 +726,83 @@ const groupSyncTimer = setInterval(() => {
|
|
|
687
726
|
}, groupSyncIntervalMs());
|
|
688
727
|
groupSyncTimer.unref();
|
|
689
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
|
+
|
|
690
806
|
// Auto-update: periodically check npm for a newer mslxdff and restart.
|
|
691
807
|
// Enable with MSLXDFF_AUTO_UPDATE=1 (hourly) or MSLXDFF_AUTO_UPDATE_MS=<ms>.
|
|
692
808
|
// Uses the same npm view/install path as `mslxdff -update`, but runs inside
|
|
@@ -743,6 +859,16 @@ function effectivePort() {
|
|
|
743
859
|
return resolvePort();
|
|
744
860
|
}
|
|
745
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
|
+
|
|
746
872
|
function refreshIntervalMs() {
|
|
747
873
|
const n = Number(process.env.MODELS_REFRESH_MS);
|
|
748
874
|
return Number.isInteger(n) && n > 0 ? n : 2 * 60 * 60 * 1000;
|
|
@@ -828,7 +954,7 @@ Usage:
|
|
|
828
954
|
mslxdff -showtoken print the current auth token
|
|
829
955
|
mslxdff -refresh-token rotate the auth token (prints the new one)
|
|
830
956
|
mslxdff -creategroup <name> create a group on this node (the group name is the password)
|
|
831
|
-
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)
|
|
832
958
|
mslxdff -group sync pull the freshest member list for all joined groups
|
|
833
959
|
mslxdff -group leave <name> leave a group (removes its members from this node)
|
|
834
960
|
mslxdff -group list list groups on this node (numbered members)
|
|
@@ -905,6 +1031,12 @@ async function printStatus() {
|
|
|
905
1031
|
const tags = [];
|
|
906
1032
|
if (id === "leader" && isLeader) tags.push("leader");
|
|
907
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
|
+
}
|
|
908
1040
|
const tag = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
909
1041
|
console.log(` ${m?.url || id}${tag}`);
|
|
910
1042
|
}
|
|
@@ -1006,6 +1138,14 @@ function fmtEvent(e) {
|
|
|
1006
1138
|
return `${head} forward -> ${e.peer} model=${m(e.model)} hops=${e.hops}${e.retry ? " (retry)" : ""}`;
|
|
1007
1139
|
case "peer-error":
|
|
1008
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)}`;
|
|
1009
1149
|
case "result":
|
|
1010
1150
|
return `${head} result ${e.status} ${m(e.model)} via=${e.via} 响应耗时 ${fmtDur(e.durationMs)}`;
|
|
1011
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.
|