mslxdff 0.1.32 → 0.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/mslxdff.js CHANGED
@@ -14,7 +14,7 @@ import { createAutoSelector } from "../src/auto.js";
14
14
  import { createPeersService } from "../src/peers.js";
15
15
  import { createEventBus } from "../src/events.js";
16
16
  import { createGroupsService, createBansService, refreshGroupMembers, syncPeersFromMembers } from "../src/groups.js";
17
- import { logDir, recentCalls, lastError, appendCall, appendError, appendEvent, recentEvents } from "../src/logs.js";
17
+ import { logDir, recentCalls, lastError, appendCall, appendError, appendEvent, recentEvents, eventsFile, callsFile, errorsFile } from "../src/logs.js";
18
18
 
19
19
  const logs = { appendCall, appendError, appendEvent };
20
20
 
@@ -85,6 +85,29 @@ if (args.includes("-uninstall") || args.includes("--uninstall")) {
85
85
  process.exit(0);
86
86
  }
87
87
 
88
+ if (args.includes("-log") || args.includes("--log") || args.includes("-logs") || args.includes("--logs")) {
89
+ const idx = args.findIndex((x) => x === "-log" || x === "--log" || x === "-logs" || x === "--logs");
90
+ const raw = args[idx + 1];
91
+ const n = Number(raw);
92
+ const count = Number.isInteger(n) && n > 0 ? n : 10;
93
+ const file = eventsFile();
94
+ const dir = logDir();
95
+ console.log(`log dir: ${dir}`);
96
+ console.log(`events: ${file}`);
97
+ const evts = recentEvents(count);
98
+ if (!evts.length) {
99
+ console.log(`(no events yet — file empty or not found)`);
100
+ } else {
101
+ console.log(`--- last ${evts.length} event(s) ---`);
102
+ for (const e of evts) console.log(fmtEvent(e));
103
+ }
104
+ // also hint for other logs
105
+ if (count <= 10) {
106
+ console.log(`\nhint: mslxdff -log 100 | calls: ${callsFile()} errors: ${errorsFile()} daemon: ${logFile()}`);
107
+ }
108
+ process.exit(0);
109
+ }
110
+
88
111
  if (args.includes("-status") || args.includes("--status") || args.includes("-s")) {
89
112
  await printStatus();
90
113
  process.exit(0);
@@ -257,14 +280,31 @@ if (groupCmd && groupCmd !== "create") {
257
280
  console.log(`${g.name} (${entries.length} member${entries.length === 1 ? "" : "s"})`);
258
281
  // probe every member endpoint concurrently for reachability + latency;
259
282
  // display order = state order so the sequence numbers stay stable for -group remove
283
+ // broadband members (relay://) are not probed — they are reachable via leader
260
284
  const probes = await Promise.all(
261
- entries.map(([id, m]) => probeHealth({ id, url: m?.url || id }))
285
+ entries.map(([id, m]) => probeHealth({ id, url: m?.url || id, kind: m?.kind, lastSeen: m?.lastSeen, publicIp: m?.publicIp }))
262
286
  );
263
287
  const leaderEntry = members.leader ? Object.entries(members).find(([id]) => id === "leader") : null;
264
288
  const leaderProbe = leaderEntry ? await probeHealth({ id: "leader", url: leaderEntry[1].url }) : null;
265
289
  const display = leaderProbe ? [...probes, leaderProbe] : probes;
266
290
  let seq = 0;
267
291
  for (const r of display) {
292
+ const m = entries.find(([eid]) => eid === r.id)?.[1] || (r.id === "leader" ? leaderEntry?.[1] : null);
293
+ const isBb = r.kind === "broadband" || String(r.url || "").startsWith("relay://") || m?.kind === "broadband";
294
+ if (isBb) {
295
+ const ago = r.lastSeen ? `${Math.round((Date.now() - r.lastSeen) / 1000)}s ago` : "no heartbeat yet";
296
+ const ip = r.publicIp || m?.publicIp || "?";
297
+ const via = r.stale ? "stale" : `via leader ${ago}`;
298
+ const stateBb = `${via} ip=${ip}`;
299
+ if (r.id === "leader") {
300
+ console.log(` leader ${r.url} ${stateBb}`);
301
+ continue;
302
+ }
303
+ seq += 1;
304
+ const label = r.id && r.id !== r.url ? ` [${r.id}]` : "";
305
+ console.log(` ${seq}. ${r.url}${label} [broadband] ${stateBb}`);
306
+ continue;
307
+ }
268
308
  const state = r.fail ? `fail ${r.fail}` : `ok ${r.ms}ms`;
269
309
  if (r.id === "leader") {
270
310
  console.log(` leader ${r.url} ${state}`);
@@ -311,12 +351,15 @@ if (groupCmd && groupCmd !== "create") {
311
351
  process.exit(0);
312
352
  }
313
353
 
314
- // -addtogroup <leader-host> <name>
354
+ // -addtogroup <leader-host> <name> [--broadband]
315
355
  const addToGroupIdx = args.findIndex((x) => x === "-addtogroup" || x === "--addtogroup");
316
356
  if (addToGroupIdx >= 0) {
317
- const [leaderHost, name] = args.slice(addToGroupIdx + 1);
318
- if (!leaderHost || !name) {
319
- 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]");
320
363
  process.exit(1);
321
364
  }
322
365
  const groups = createGroupsService({});
@@ -325,25 +368,34 @@ if (addToGroupIdx >= 0) {
325
368
  const leaderUrl = leaderHost.includes("://")
326
369
  ? leaderHost.replace(/\/+$/, "")
327
370
  : `http://${leaderHost}${leaderHost.includes(":") ? "" : ":8989"}`;
328
- const 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
+ }
329
380
  try {
330
381
  const res = await fetch(`${leaderUrl}/v1/groups/join`, {
331
382
  method: "POST",
332
383
  headers: { "Content-Type": "application/json" },
333
- body: JSON.stringify({ name, key: name, leaderUrl, myPort, token: myToken }),
384
+ body: JSON.stringify(joinBody),
334
385
  });
335
386
  if (!res.ok) {
336
387
  const text = await res.text().catch(() => "");
337
388
  throw new Error(`join failed (HTTP ${res.status}): ${text}`);
338
389
  }
339
390
  const data = await res.json();
340
- const myUrl = data.you?.url || "";
341
- 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 });
342
394
  const synced = await syncAllJoinedGroups({ peers, groups });
343
395
  const s = synced.find((x) => x.name === name);
344
- console.log(`joined group "${name}" at ${leaderUrl}`);
396
+ console.log(`joined group "${name}" at ${leaderUrl}${isBroadband ? " [broadband]" : ""}`);
345
397
  if (s?.error) console.log(` local failover setup failed: ${s.error}`);
346
- else console.log(` ${s?.added ?? 0} failover target(s) configured`);
398
+ else console.log(` ${s?.added ?? 0} failover target(s) configured${isBroadband ? " (broadband via leader, local 127.0.0.1)" : ""}`);
347
399
  } catch (err) {
348
400
  console.error(`join failed: ${err.message}`);
349
401
  process.exit(1);
@@ -363,6 +415,7 @@ if (resetBanArg !== null || args.includes("-resetban") || args.includes("--reset
363
415
 
364
416
  function markJoined(entry) {
365
417
  const { name } = entry;
418
+ if (!entry.kind) entry.kind = "static";
366
419
  const list = loadGroupsJoined().filter((g) => g.name !== name);
367
420
  saveGroupsJoined([...list, entry]);
368
421
  }
@@ -441,8 +494,15 @@ function errMsg(err) { return String(err?.message || err); }
441
494
 
442
495
  // Probe a member's health endpoint concurrently (v1/health preferred,
443
496
  // falling back to /health). Returns { id, url, ms, rank } or { id, url, fail }.
444
- 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 } = {}) {
445
499
  if (!url) return { id, url, fail: "no url", rank: 3 };
500
+ const isBb = kind === "broadband" || String(url).startsWith("relay://");
501
+ if (isBb) {
502
+ const staleMs = Number(process.env.MSLXDFF_BROADBAND_STALE_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_STALE_MS) : 90_000;
503
+ const stale = typeof lastSeen === "number" ? Date.now() - lastSeen > staleMs : true;
504
+ return { id, url, kind: "broadband", lastSeen, publicIp, stale, rank: stale ? 2 : 0 };
505
+ }
446
506
  const base = String(url).replace(/\/+$/, "");
447
507
  const startedAt = Date.now();
448
508
  try {
@@ -477,6 +537,7 @@ async function syncAllJoinedGroups({ peers, groups }) {
477
537
  memberName: g.memberName,
478
538
  url: g.myUrl,
479
539
  token: myToken,
540
+ kind: g.kind,
480
541
  });
481
542
  results.push({
482
543
  name: g.name,
@@ -603,7 +664,8 @@ const bans = createBansService({ windowMs: banWindowMs(), threshold: banThreshol
603
664
  const isDebug = process.env.MSLXDFF_DEBUG === "1";
604
665
  const bus = createEventBus();
605
666
  const router = createRouter({ token, upstream, models, auto, logs, peers, maxHops: maxHopsValue(), groups, bans, bus });
606
- const srv = startServer({ router, signals: !isDebug });
667
+ const listenHost = effectiveHost();
668
+ const srv = startServer({ router, signals: !isDebug, host: listenHost });
607
669
 
608
670
  // -debug: push every event straight to this terminal.
609
671
  if (isDebug) {
@@ -664,6 +726,83 @@ const groupSyncTimer = setInterval(() => {
664
726
  }, groupSyncIntervalMs());
665
727
  groupSyncTimer.unref();
666
728
 
729
+ // Broadband heartbeat + relay poll (only for broadband members)
730
+ const broadbandGroups = () => loadGroupsJoined().filter((g) => g.kind === "broadband" && g.leaderUrl);
731
+ if (broadbandGroups().length) {
732
+ const doHeartbeat = async () => {
733
+ for (const g of broadbandGroups()) {
734
+ try {
735
+ const res = await fetch(`${g.leaderUrl}/v1/groups/relay/heartbeat`, {
736
+ method: "POST",
737
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
738
+ body: JSON.stringify({ name: g.name, group: g.name }),
739
+ signal: AbortSignal.timeout(5000),
740
+ });
741
+ if (!res.ok) {
742
+ const txt = await res.text().catch(() => "");
743
+ console.log(`broadband heartbeat ${g.name}: ${res.status} ${txt.slice(0, 100)}`);
744
+ }
745
+ } catch (err) {
746
+ console.log(`broadband heartbeat ${g.name}: failed — ${errMsg(err)}`);
747
+ }
748
+ }
749
+ };
750
+ const doPoll = async () => {
751
+ for (const g of broadbandGroups()) {
752
+ try {
753
+ const pollRes = await fetch(`${g.leaderUrl}/v1/groups/relay/poll`, {
754
+ method: "POST",
755
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
756
+ body: JSON.stringify({ name: g.name, group: g.name }),
757
+ signal: AbortSignal.timeout(8000),
758
+ });
759
+ if (!pollRes.ok) continue;
760
+ const data = await pollRes.json().catch(() => ({}));
761
+ const items = data.data || [];
762
+ for (const item of items) {
763
+ const { reqId, body } = item;
764
+ let result;
765
+ try {
766
+ const upRes = await upstream.chat(body);
767
+ const ct = upRes.headers.get("content-type") || "";
768
+ const isStream = Boolean(body?.stream) || ct.includes("text/event-stream");
769
+ if (isStream && upRes.body) {
770
+ // collect stream chunks for relay (simplified: buffer then forward as SSE)
771
+ let collected = "";
772
+ for await (const chunk of upRes.body) {
773
+ collected += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
774
+ }
775
+ result = { status: upRes.status, headers: { "Content-Type": "text/event-stream" }, body: collected };
776
+ } else {
777
+ const txt = await upRes.text();
778
+ let parsed;
779
+ try { parsed = JSON.parse(txt); } catch { parsed = txt; }
780
+ result = { status: upRes.status, headers: { "Content-Type": upRes.headers.get("content-type") || "application/json" }, body: typeof parsed === "string" ? parsed : JSON.stringify(parsed) };
781
+ }
782
+ } catch (err) {
783
+ result = { status: 502, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ error: errMsg(err) }) };
784
+ }
785
+ try {
786
+ await fetch(`${g.leaderUrl}/v1/groups/relay/result`, {
787
+ method: "POST",
788
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
789
+ body: JSON.stringify({ name: g.name, group: g.name, reqId, result }),
790
+ signal: AbortSignal.timeout(5000),
791
+ });
792
+ } catch {}
793
+ }
794
+ } catch {}
795
+ }
796
+ };
797
+ // initial heartbeat
798
+ doHeartbeat().catch(() => {});
799
+ const hbTimer = setInterval(doHeartbeat, 30_000);
800
+ hbTimer.unref();
801
+ const pollTimer = setInterval(doPoll, 1000);
802
+ pollTimer.unref();
803
+ console.log(`broadband relay: heartbeat 30s + poll 1s for ${broadbandGroups().length} group(s)`);
804
+ }
805
+
667
806
  // Auto-update: periodically check npm for a newer mslxdff and restart.
668
807
  // Enable with MSLXDFF_AUTO_UPDATE=1 (hourly) or MSLXDFF_AUTO_UPDATE_MS=<ms>.
669
808
  // Uses the same npm view/install path as `mslxdff -update`, but runs inside
@@ -720,6 +859,16 @@ function effectivePort() {
720
859
  return resolvePort();
721
860
  }
722
861
 
862
+ function effectiveHost() {
863
+ const envHost = process.env.MSLXDFF_HOST || process.env.MSLXDFF_BIND_HOST;
864
+ if (typeof envHost === "string" && envHost.trim()) return envHost.trim();
865
+ try {
866
+ const joined = loadGroupsJoined();
867
+ if (joined.some((g) => g.kind === "broadband")) return "127.0.0.1";
868
+ } catch {}
869
+ return "0.0.0.0";
870
+ }
871
+
723
872
  function refreshIntervalMs() {
724
873
  const n = Number(process.env.MODELS_REFRESH_MS);
725
874
  return Number.isInteger(n) && n > 0 ? n : 2 * 60 * 60 * 1000;
@@ -793,6 +942,7 @@ Usage:
793
942
  mslxdff start as a background daemon and exit (status + help if one is already running)
794
943
  mslxdff -d start as a background daemon
795
944
  mslxdff -status show current status (daemon, models, recent calls, last error)
945
+ mslxdff -log [N] show last N events (default 10, e.g. -log 100)
796
946
  mslxdff -model list list the free models this proxy serves (cached)
797
947
  mslxdff -model status show per-model health status (normal/limit/error)
798
948
  mslxdff -model refresh force-refresh the model cache from the upstream
@@ -804,7 +954,7 @@ Usage:
804
954
  mslxdff -showtoken print the current auth token
805
955
  mslxdff -refresh-token rotate the auth token (prints the new one)
806
956
  mslxdff -creategroup <name> create a group on this node (the group name is the password)
807
- mslxdff -addtogroup <leader-host> <name> join a group via its leader host (default port 8989)
957
+ mslxdff -addtogroup <leader-host> <name> [--broadband] join a group via its leader host (default port 8989) — broadband: 宽带动态IP成员(经Leader中继,无需公网入站,默认127.0.0.1)
808
958
  mslxdff -group sync pull the freshest member list for all joined groups
809
959
  mslxdff -group leave <name> leave a group (removes its members from this node)
810
960
  mslxdff -group list list groups on this node (numbered members)
@@ -881,6 +1031,12 @@ async function printStatus() {
881
1031
  const tags = [];
882
1032
  if (id === "leader" && isLeader) tags.push("leader");
883
1033
  if (m?.url && m.url === g.myUrl) tags.push("this node");
1034
+ if (m?.kind === "broadband") tags.push("broadband");
1035
+ if (m?.kind === "broadband" && m?.publicIp) tags.push(`ip=${m.publicIp}`);
1036
+ if (m?.kind === "broadband" && m?.lastSeen) {
1037
+ const ago = Math.round((Date.now() - m.lastSeen) / 1000);
1038
+ tags.push(ago < 90 ? `via leader ${ago}s ago` : "stale");
1039
+ }
884
1040
  const tag = tags.length ? ` [${tags.join(", ")}]` : "";
885
1041
  console.log(` ${m?.url || id}${tag}`);
886
1042
  }
@@ -982,6 +1138,14 @@ function fmtEvent(e) {
982
1138
  return `${head} forward -> ${e.peer} model=${m(e.model)} hops=${e.hops}${e.retry ? " (retry)" : ""}`;
983
1139
  case "peer-error":
984
1140
  return `${head} peer err ${e.peer} ${e.status ? `HTTP ${e.status}` : "network"}: ${m(e.message)}`;
1141
+ case "relay-ip-change":
1142
+ return `${head} relay ip ${e.member || e.id || "?"} ${e.oldIp || "?"} -> ${e.newIp || e.publicIp || "?"} via leader`;
1143
+ case "relay-forward":
1144
+ return `${head} relay fwd ${e.target || e.peer || "?"} via leader model=${m(e.model)} hops=${e.hops || 0}`;
1145
+ case "relay-heartbeat":
1146
+ return `${head} relay hb ${e.member || "?"} ip=${e.ip || "?"} lastSeen=${e.lastSeen ? new Date(e.lastSeen).toISOString().slice(11,19) : "?"}`;
1147
+ case "client-abort":
1148
+ return `${head} client abort ${m(e.model)} total=${fmtDur(e.totalMs)}`;
985
1149
  case "result":
986
1150
  return `${head} result ${e.status} ${m(e.model)} via=${e.via} 响应耗时 ${fmtDur(e.durationMs)}`;
987
1151
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
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.