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 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 [leaderHost, name] = args.slice(addToGroupIdx + 1);
341
- if (!leaderHost || !name) {
342
- console.error("usage: mslxdff -addtogroup <leader-host> <name>");
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 myPort = effectivePort();
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({ name, key: name, leaderUrl, myPort, token: myToken }),
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
- markJoined({ name, leaderUrl, myUrl, memberName: myUrl });
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
- async function probeHealth({ id, url } = {}) {
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 srv = startServer({ router, signals: !isDebug });
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
- // Enable with MSLXDFF_AUTO_UPDATE=1 (hourly) or MSLXDFF_AUTO_UPDATE_MS=<ms>.
692
- // Uses the same npm view/install path as `mslxdff -update`, but runs inside
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
- checkAndAutoUpdate().catch((err) => console.log(`auto-update check failed: ${errMsg(err)}`));
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
- const info = await run(npmCmd(), ["view", "mslxdff", "version", "dist-tags.latest"]);
705
- if (info.err) throw new Error(info.err.message);
706
- const parts = (info.stdout || "").trim().split(/\s+/).filter(Boolean);
707
- const latest = parts[parts.length - 1];
708
- if (!latest || latest === VERSION) return;
709
- // simple semver compare: skip if latest is not newer
710
- if (compareSemver(latest, VERSION) <= 0) return;
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) throw new Error(up.err.message);
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
- try { stopDaemon(); } catch {}
716
- // startDaemon re-reads VERSION from the newly installed package on next boot;
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
- if (raw === undefined || raw === null || raw === "") return 0;
784
- if (raw === "1" || String(raw).toLowerCase() === "true") return 60 * 60 * 1000;
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 : 0;
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=hourly, or ms interval (0=off)
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
- execFile(cmd, args, { timeout: 120_000, ...opts }, (err, stdout, stderr) => resolve({ err, stdout, stderr }));
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", "version", "dist-tags.latest"]);
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
- const [version, latest] = (info.stdout || "").trim().split(/\s+/);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
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]] = { url, token: token || "" };
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
- members[id] = { url, token: token || "" };
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
- writeMember(group, memberName, url, token);
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
- writeMember(group, memberName, url, token);
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({ name, memberName, url, token }),
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
- return typeof err === "number" && now() - err < cooldownMs;
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, isHot, stat, ordered, orderedByLastError, available, next,
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
- const memberUrl = String(body.url || youUrl);
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.