mslxdff 0.1.33 → 0.1.35
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 +270 -39
- 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,37 +726,163 @@ 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
|
-
//
|
|
692
|
-
//
|
|
693
|
-
// the daemon so no manual intervention is needed.
|
|
807
|
+
// Default: hourly (no env needed). Disable with MSLXDFF_AUTO_UPDATE=0/off/false.
|
|
808
|
+
// Tuning: MSLXDFF_AUTO_UPDATE=1/true → hourly, or MSLXDFF_AUTO_UPDATE_MS=<ms>.
|
|
694
809
|
const autoUpdateMs = autoUpdateIntervalMs();
|
|
810
|
+
function emitAutoUpdate(type, data = {}) {
|
|
811
|
+
const entry = { ts: Date.now(), type, ...data };
|
|
812
|
+
try { bus?.emit(entry); } catch {}
|
|
813
|
+
try { logs?.appendEvent?.(entry); } catch {}
|
|
814
|
+
// also to daemon.log for tail
|
|
815
|
+
const line = `[auto-update] ${type} ${JSON.stringify(data)}`;
|
|
816
|
+
console.log(line);
|
|
817
|
+
}
|
|
695
818
|
if (autoUpdateMs) {
|
|
696
819
|
console.log(`auto-update enabled: checking every ${Math.round(autoUpdateMs / 60000)}m`);
|
|
820
|
+
emitAutoUpdate("auto-update-enabled", { intervalMs: autoUpdateMs, current: VERSION });
|
|
821
|
+
// run once shortly after start (30s) so a newly deployed fix is picked up quickly,
|
|
822
|
+
// then on the regular interval
|
|
823
|
+
setTimeout(() => {
|
|
824
|
+
emitAutoUpdate("auto-update-check", { current: VERSION });
|
|
825
|
+
checkAndAutoUpdate().catch((err) => {
|
|
826
|
+
console.log(`auto-update check failed: ${errMsg(err)}`);
|
|
827
|
+
emitAutoUpdate("auto-update-failed", { error: errMsg(err) });
|
|
828
|
+
});
|
|
829
|
+
}, 30_000).unref?.();
|
|
697
830
|
const autoUpdateTimer = setInterval(() => {
|
|
698
|
-
|
|
831
|
+
emitAutoUpdate("auto-update-check", { current: VERSION });
|
|
832
|
+
checkAndAutoUpdate().catch((err) => {
|
|
833
|
+
console.log(`auto-update check failed: ${errMsg(err)}`);
|
|
834
|
+
emitAutoUpdate("auto-update-failed", { error: errMsg(err) });
|
|
835
|
+
});
|
|
699
836
|
}, autoUpdateMs);
|
|
700
837
|
autoUpdateTimer.unref();
|
|
838
|
+
} else {
|
|
839
|
+
console.log(`auto-update disabled (set MSLXDFF_AUTO_UPDATE=1 to enable hourly)`);
|
|
840
|
+
emitAutoUpdate("auto-update-disabled", { current: VERSION });
|
|
701
841
|
}
|
|
702
842
|
|
|
703
843
|
async function checkAndAutoUpdate() {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
844
|
+
emitAutoUpdate("auto-update-query", { current: VERSION });
|
|
845
|
+
const info = await run(npmCmd(), ["view", "mslxdff", "dist-tags.latest", "--json"]);
|
|
846
|
+
if (info.err) {
|
|
847
|
+
emitAutoUpdate("auto-update-query-failed", { error: info.err.message || String(info.stderr || "").slice(0, 500) });
|
|
848
|
+
throw new Error(info.err.message || String(info.stderr || "").slice(0, 500));
|
|
849
|
+
}
|
|
850
|
+
let latest = "";
|
|
851
|
+
try {
|
|
852
|
+
latest = JSON.parse(String(info.stdout || "").trim());
|
|
853
|
+
if (Array.isArray(latest)) latest = latest[latest.length - 1];
|
|
854
|
+
latest = String(latest || "").replace(/^v/, "").trim();
|
|
855
|
+
} catch {
|
|
856
|
+
const raw = String(info.stdout || "").trim();
|
|
857
|
+
const m = raw.match(/(\d+\.\d+\.\d+[^\s'"]*)/);
|
|
858
|
+
latest = m ? m[1] : raw.split(/\s+/).pop()?.replace(/['"]/g, "") || "";
|
|
859
|
+
}
|
|
860
|
+
latest = latest.replace(/['"]/g, "").trim();
|
|
861
|
+
emitAutoUpdate("auto-update-queried", { current: VERSION, latest, stdout: String(info.stdout || "").trim().slice(0, 200) });
|
|
862
|
+
if (!latest || latest === VERSION) {
|
|
863
|
+
emitAutoUpdate("auto-update-noop", { current: VERSION, latest });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (compareSemver(latest, VERSION) <= 0) {
|
|
867
|
+
emitAutoUpdate("auto-update-noop", { current: VERSION, latest, reason: "not newer" });
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
emitAutoUpdate("auto-update-found", { current: VERSION, latest });
|
|
711
871
|
console.log(`auto-update: v${VERSION} -> v${latest}, installing...`);
|
|
872
|
+
emitAutoUpdate("auto-update-installing", { current: VERSION, latest });
|
|
712
873
|
const up = await run(npmCmd(), ["install", "-g", `mslxdff@${latest}`]);
|
|
713
|
-
if (up.err)
|
|
874
|
+
if (up.err) {
|
|
875
|
+
emitAutoUpdate("auto-update-install-failed", { current: VERSION, latest, error: up.err.message || String(up.stderr || "").slice(0, 500) });
|
|
876
|
+
throw new Error(up.err.message || String(up.stderr || "").slice(0, 500));
|
|
877
|
+
}
|
|
878
|
+
emitAutoUpdate("auto-update-installed", { current: VERSION, latest, stdout: String(up.stdout || "").slice(0, 500) });
|
|
714
879
|
console.log(`auto-update: installed v${latest}, restarting daemon...`);
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
// for the current process we just respawn with the new code.
|
|
880
|
+
emitAutoUpdate("auto-update-restarting", { current: VERSION, latest });
|
|
881
|
+
try { stopDaemon(); } catch (e) { emitAutoUpdate("auto-update-stop-failed", { error: errMsg(e) }); }
|
|
718
882
|
const newPid = startDaemon([]);
|
|
719
883
|
await waitForHealth(resolvePort(), 8000);
|
|
720
884
|
console.log(`auto-update: restarted as v${latest} (pid ${newPid})`);
|
|
885
|
+
emitAutoUpdate("auto-update-restarted", { current: VERSION, latest, newPid });
|
|
721
886
|
}
|
|
722
887
|
|
|
723
888
|
function compareSemver(a, b) {
|
|
@@ -743,6 +908,16 @@ function effectivePort() {
|
|
|
743
908
|
return resolvePort();
|
|
744
909
|
}
|
|
745
910
|
|
|
911
|
+
function effectiveHost() {
|
|
912
|
+
const envHost = process.env.MSLXDFF_HOST || process.env.MSLXDFF_BIND_HOST;
|
|
913
|
+
if (typeof envHost === "string" && envHost.trim()) return envHost.trim();
|
|
914
|
+
try {
|
|
915
|
+
const joined = loadGroupsJoined();
|
|
916
|
+
if (joined.some((g) => g.kind === "broadband")) return "127.0.0.1";
|
|
917
|
+
} catch {}
|
|
918
|
+
return "0.0.0.0";
|
|
919
|
+
}
|
|
920
|
+
|
|
746
921
|
function refreshIntervalMs() {
|
|
747
922
|
const n = Number(process.env.MODELS_REFRESH_MS);
|
|
748
923
|
return Number.isInteger(n) && n > 0 ? n : 2 * 60 * 60 * 1000;
|
|
@@ -780,10 +955,13 @@ function groupSyncIntervalMs() {
|
|
|
780
955
|
|
|
781
956
|
function autoUpdateIntervalMs() {
|
|
782
957
|
const raw = process.env.MSLXDFF_AUTO_UPDATE_MS ?? process.env.MSLXDFF_AUTO_UPDATE;
|
|
783
|
-
|
|
784
|
-
if (raw ===
|
|
958
|
+
// default: hourly when env not set; explicit 0/off/false disables
|
|
959
|
+
if (raw === undefined || raw === null || raw === "") return 60 * 60 * 1000;
|
|
960
|
+
const s = String(raw).trim().toLowerCase();
|
|
961
|
+
if (s === "0" || s === "off" || s === "false" || s === "no" || s === "disable" || s === "disabled") return 0;
|
|
962
|
+
if (s === "1" || s === "true" || s === "on" || s === "yes" || s === "enable" || s === "enabled") return 60 * 60 * 1000;
|
|
785
963
|
const n = Number(raw);
|
|
786
|
-
return Number.isInteger(n) && n > 0 ? n :
|
|
964
|
+
return Number.isInteger(n) && n > 0 ? n : 60 * 60 * 1000;
|
|
787
965
|
}
|
|
788
966
|
|
|
789
967
|
function banWindowMs() {
|
|
@@ -828,7 +1006,7 @@ Usage:
|
|
|
828
1006
|
mslxdff -showtoken print the current auth token
|
|
829
1007
|
mslxdff -refresh-token rotate the auth token (prints the new one)
|
|
830
1008
|
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)
|
|
1009
|
+
mslxdff -addtogroup <leader-host> <name> [--broadband] join a group via its leader host (default port 8989) — broadband: 宽带动态IP成员(经Leader中继,无需公网入站,默认127.0.0.1)
|
|
832
1010
|
mslxdff -group sync pull the freshest member list for all joined groups
|
|
833
1011
|
mslxdff -group leave <name> leave a group (removes its members from this node)
|
|
834
1012
|
mslxdff -group list list groups on this node (numbered members)
|
|
@@ -853,7 +1031,7 @@ Environment:
|
|
|
853
1031
|
MSLXDFF_MAX_HOPS max peer-forwarding depth (default 3)
|
|
854
1032
|
MSLXDFF_BAN_THRESHOLD failed joins before an ip is banned (default 5)
|
|
855
1033
|
MSLXDFF_BAN_WINDOW_MS ban duration after too many failures (default 48h)
|
|
856
|
-
MSLXDFF_AUTO_UPDATE auto-update: 1/true
|
|
1034
|
+
MSLXDFF_AUTO_UPDATE auto-update: hourly by default, 0/off/false to disable, 1/true or ms
|
|
857
1035
|
MSLXDFF_AUTO_UPDATE_MS same as above, explicit ms (overrides AUTO_UPDATE)
|
|
858
1036
|
`);
|
|
859
1037
|
}
|
|
@@ -905,6 +1083,12 @@ async function printStatus() {
|
|
|
905
1083
|
const tags = [];
|
|
906
1084
|
if (id === "leader" && isLeader) tags.push("leader");
|
|
907
1085
|
if (m?.url && m.url === g.myUrl) tags.push("this node");
|
|
1086
|
+
if (m?.kind === "broadband") tags.push("broadband");
|
|
1087
|
+
if (m?.kind === "broadband" && m?.publicIp) tags.push(`ip=${m.publicIp}`);
|
|
1088
|
+
if (m?.kind === "broadband" && m?.lastSeen) {
|
|
1089
|
+
const ago = Math.round((Date.now() - m.lastSeen) / 1000);
|
|
1090
|
+
tags.push(ago < 90 ? `via leader ${ago}s ago` : "stale");
|
|
1091
|
+
}
|
|
908
1092
|
const tag = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
909
1093
|
console.log(` ${m?.url || id}${tag}`);
|
|
910
1094
|
}
|
|
@@ -1006,8 +1190,42 @@ function fmtEvent(e) {
|
|
|
1006
1190
|
return `${head} forward -> ${e.peer} model=${m(e.model)} hops=${e.hops}${e.retry ? " (retry)" : ""}`;
|
|
1007
1191
|
case "peer-error":
|
|
1008
1192
|
return `${head} peer err ${e.peer} ${e.status ? `HTTP ${e.status}` : "network"}: ${m(e.message)}`;
|
|
1193
|
+
case "relay-ip-change":
|
|
1194
|
+
return `${head} relay ip ${e.member || e.id || "?"} ${e.oldIp || "?"} -> ${e.newIp || e.publicIp || "?"} via leader`;
|
|
1195
|
+
case "relay-forward":
|
|
1196
|
+
return `${head} relay fwd ${e.target || e.peer || "?"} via leader model=${m(e.model)} hops=${e.hops || 0}`;
|
|
1197
|
+
case "relay-heartbeat":
|
|
1198
|
+
return `${head} relay hb ${e.member || "?"} ip=${e.ip || "?"} lastSeen=${e.lastSeen ? new Date(e.lastSeen).toISOString().slice(11,19) : "?"}`;
|
|
1199
|
+
case "client-abort":
|
|
1200
|
+
return `${head} client abort ${m(e.model)} total=${fmtDur(e.totalMs)}`;
|
|
1009
1201
|
case "result":
|
|
1010
1202
|
return `${head} result ${e.status} ${m(e.model)} via=${e.via} 响应耗时 ${fmtDur(e.durationMs)}`;
|
|
1203
|
+
case "auto-update-enabled":
|
|
1204
|
+
return `${head} auto-update enabled every ${Math.round((e.intervalMs||0)/60000)}m current=${e.current}`;
|
|
1205
|
+
case "auto-update-disabled":
|
|
1206
|
+
return `${head} auto-update disabled current=${e.current}`;
|
|
1207
|
+
case "auto-update-check":
|
|
1208
|
+
return `${head} auto-update checking current=${e.current}`;
|
|
1209
|
+
case "auto-update-query":
|
|
1210
|
+
return `${head} auto-update querying npm current=${e.current}`;
|
|
1211
|
+
case "auto-update-queried":
|
|
1212
|
+
return `${head} auto-update queried current=${e.current} latest=${e.latest} raw=${(e.stdout||"").slice(0,80)}`;
|
|
1213
|
+
case "auto-update-noop":
|
|
1214
|
+
return `${head} auto-update noop current=${e.current} latest=${e.latest}${e.reason?` reason=${e.reason}`:""}`;
|
|
1215
|
+
case "auto-update-found":
|
|
1216
|
+
return `${head} auto-update NEW v${e.current} -> v${e.latest} 发现新版本`;
|
|
1217
|
+
case "auto-update-installing":
|
|
1218
|
+
return `${head} auto-update installing v${e.latest}...`;
|
|
1219
|
+
case "auto-update-installed":
|
|
1220
|
+
return `${head} auto-update installed v${e.latest}`;
|
|
1221
|
+
case "auto-update-restarting":
|
|
1222
|
+
return `${head} auto-update restarting daemon to v${e.latest}...`;
|
|
1223
|
+
case "auto-update-restarted":
|
|
1224
|
+
return `${head} auto-update restarted pid=${e.newPid} v${e.current} -> v${e.latest} 升级完成`;
|
|
1225
|
+
case "auto-update-failed":
|
|
1226
|
+
case "auto-update-query-failed":
|
|
1227
|
+
case "auto-update-install-failed":
|
|
1228
|
+
return `${head} auto-update failed ${e.type} error=${e.error||""}`;
|
|
1011
1229
|
default:
|
|
1012
1230
|
return `${head} ${e?.type || "?"} ${JSON.stringify(e || {})}`;
|
|
1013
1231
|
}
|
|
@@ -1027,22 +1245,35 @@ function npmCmd() {
|
|
|
1027
1245
|
}
|
|
1028
1246
|
|
|
1029
1247
|
function run(cmd, args, opts = {}) {
|
|
1248
|
+
const isWin = process.platform === "win32";
|
|
1030
1249
|
return new Promise((resolve) => {
|
|
1031
|
-
|
|
1250
|
+
// npm.cmd is a batch file on Windows — execFile needs shell:true to find it via PATHEXT
|
|
1251
|
+
const execOpts = isWin ? { shell: true, windowsHide: true } : {};
|
|
1252
|
+
execFile(cmd, args, { timeout: 120_000, ...execOpts, ...opts }, (err, stdout, stderr) => resolve({ err, stdout, stderr }));
|
|
1032
1253
|
});
|
|
1033
1254
|
}
|
|
1034
1255
|
|
|
1035
1256
|
async function updateSelf() {
|
|
1036
1257
|
console.log(`mslxdff v${VERSION} — checking for updates…`);
|
|
1037
|
-
const info = await run(npmCmd(), ["view", "mslxdff", "
|
|
1258
|
+
const info = await run(npmCmd(), ["view", "mslxdff", "dist-tags.latest", "--json"]);
|
|
1038
1259
|
if (info.err) {
|
|
1039
|
-
console.error(`could not query npm: ${info.err.message}`);
|
|
1260
|
+
console.error(`could not query npm: ${info.err.message || String(info.stderr || "").slice(0, 500)}`);
|
|
1040
1261
|
process.exit(1);
|
|
1041
1262
|
}
|
|
1042
|
-
|
|
1263
|
+
let latest = "";
|
|
1264
|
+
try {
|
|
1265
|
+
latest = JSON.parse(String(info.stdout || "").trim());
|
|
1266
|
+
if (Array.isArray(latest)) latest = latest[latest.length - 1];
|
|
1267
|
+
latest = String(latest || "").trim();
|
|
1268
|
+
} catch {
|
|
1269
|
+
const m = String(info.stdout || "").trim().match(/(\d+\.\d+\.\d+[^\s'"]*)/);
|
|
1270
|
+
latest = m ? m[1] : "";
|
|
1271
|
+
}
|
|
1272
|
+
latest = String(latest || "").replace(/['"]/g, "").trim();
|
|
1273
|
+
const version = VERSION;
|
|
1043
1274
|
console.log(` installed: ${version}`);
|
|
1044
|
-
console.log(` latest: ${latest}`);
|
|
1045
|
-
if (version === latest) {
|
|
1275
|
+
console.log(` latest: ${latest || "unknown"}`);
|
|
1276
|
+
if (!latest || version === latest || compareSemver(latest, version) <= 0) {
|
|
1046
1277
|
console.log("already up to date");
|
|
1047
1278
|
process.exit(0);
|
|
1048
1279
|
}
|
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.
|