mslxdff 0.1.89 → 0.1.91
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/package.json +1 -1
- package/src/chat/engine.js +160 -0
- package/src/chat/repl.js +32 -299
- package/src/chat/terminal.js +95 -0
- package/src/chat/tool-handlers.js +67 -0
- package/src/chat/upstream.js +1 -4
- package/src/chat-pipeline/auto-race.js +124 -0
- package/src/chat-pipeline/engine.js +10 -234
- package/src/chat-pipeline/serial-trial.js +144 -0
- package/src/cli/commands/model/list-providers.js +75 -0
- package/src/cli/commands/model/list-render.js +79 -0
- package/src/cli/commands/model/list.js +208 -0
- package/src/cli/commands/model/picks.js +45 -0
- package/src/cli/commands/model/stats.js +43 -0
- package/src/cli/commands/model/status.js +47 -0
- package/src/cli/commands/model.js +17 -371
- package/src/cli/help.js +2 -1
- package/src/providers/cline/chat.js +15 -2
- package/src/routes/chat/relay-pipeline.js +26 -0
- package/src/runtime/bootstrap.js +14 -469
- package/src/runtime/broadband-stream.js +76 -0
- package/src/runtime/broadband.js +97 -0
- package/src/runtime/group-sync.js +28 -0
- package/src/runtime/providers-setup.js +147 -0
- package/src/runtime/server-lifecycle.js +156 -0
- package/src/state/facade.js +1 -0
- package/src/state/schemas/model.js +41 -0
- package/src/upstream-responses.js +64 -0
- package/src/upstream.js +5 -59
package/src/runtime/bootstrap.js
CHANGED
|
@@ -1,477 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { createUpstreamClient } from "../upstream.js";
|
|
7
|
-
import { createModelsService } from "../models.js";
|
|
8
|
-
import { loadToken } from "../state.js";
|
|
9
|
-
import { createAutoSelector } from "../auto.js";
|
|
10
|
-
import { createPeersService } from "../peers.js";
|
|
11
|
-
import { createEventBus } from "../events.js";
|
|
12
|
-
import { createGroupsService, createBansService, refreshGroupMembers, syncPeersFromMembers } from "../groups.js";
|
|
13
|
-
import { logDir, appendCall, appendError, appendEvent } from "../logs.js";
|
|
14
|
-
import { loadPlugins, runHook, resolvePluginDirs } from "../plugins.js";
|
|
15
|
-
import { createOpenCodeProvider } from "../providers/opencode.js";
|
|
16
|
-
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs } from "../state.js";
|
|
17
|
-
import { effectiveHost, refreshIntervalMs, modelCooldownMs, slowCooldownMs, peerCooldownMs, peerHeatMs, maxHopsValue, groupSyncIntervalMs, autoUpdateIntervalMs, banWindowMs, banThreshold } from "../cli/policy.js";
|
|
18
|
-
import { fmtEvent } from "../cli/format.js";
|
|
19
|
-
import { errMsg, npmCmd, run } from "../cli/util.js";
|
|
20
|
-
import { loadGroupsJoined } from "../state.js";
|
|
21
|
-
import { writePid } from "../daemon.js";
|
|
22
|
-
import { resolvePort } from "../server.js";
|
|
1
|
+
import { appendCall, appendError, appendEvent } from "../logs.js";
|
|
2
|
+
import { setupProviders } from "./providers-setup.js";
|
|
3
|
+
import { startServerLifecycle } from "./server-lifecycle.js";
|
|
4
|
+
import { startGroupSync } from "./group-sync.js";
|
|
5
|
+
import { startBroadband } from "./broadband.js";
|
|
23
6
|
|
|
24
7
|
const logs = { appendCall, appendError, appendEvent };
|
|
25
8
|
|
|
9
|
+
/**
|
|
10
|
+
* daemon 启动门面 — 仅编排:组装世界 → 服务生命周期 → 群组同步 → 宽带中继 → 自更新。
|
|
11
|
+
* 重活下沉 providers-setup / server-lifecycle / group-sync / broadband(-stream)。
|
|
12
|
+
*/
|
|
26
13
|
export async function startDaemonMain(VERSION) {
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const { plugins: loadedPlugins, errors: pluginErrors } = await loadPlugins({ dirs: pluginDirs });
|
|
33
|
-
for (const e of pluginErrors) {
|
|
34
|
-
console.log(`plugin load failed: ${e.file} — ${e.error}`);
|
|
35
|
-
appendEvent({ ts: Date.now(), type: "plugin-load-error", file: e.file, error: e.error });
|
|
36
|
-
}
|
|
37
|
-
if (loadedPlugins.length) {
|
|
38
|
-
console.log(`plugins loaded (${loadedPlugins.length}): ${loadedPlugins.map((p) => `${p.name}${p.version ? `@${p.version}` : ""}`).join(", ")}`);
|
|
39
|
-
appendEvent({ ts: Date.now(), type: "plugins-loaded", plugins: loadedPlugins.map((p) => ({ name: p.name, version: p.version })) });
|
|
40
|
-
}
|
|
41
|
-
const upstreamHooks = loadedPlugins.length
|
|
42
|
-
? (name, ctx) => runHook(loadedPlugins, name, ctx)
|
|
43
|
-
: null;
|
|
44
|
-
const providerPlugin = loadedPlugins.find((p) => typeof p.createUpstream === "function");
|
|
45
|
-
let upstream;
|
|
46
|
-
const baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
|
|
47
|
-
let providers = [];
|
|
48
|
-
if (providerPlugin) {
|
|
49
|
-
if (!upstream) {
|
|
50
|
-
try {
|
|
51
|
-
upstream = await providerPlugin.createUpstream({ baseUrl, authToken: process.env.UPSTREAM_AUTH_TOKEN || "public", env: process.env });
|
|
52
|
-
console.log(`upstream provider replaced by plugin: ${providerPlugin.name}`);
|
|
53
|
-
appendEvent({ ts: Date.now(), type: "plugin-upstream-active", plugin: providerPlugin.name });
|
|
54
|
-
} catch (err) {
|
|
55
|
-
console.log(`plugin upstream (${providerPlugin.name}) failed: ${errMsg(err)} — falling back to default`);
|
|
56
|
-
appendEvent({ ts: Date.now(), type: "plugin-upstream-error", plugin: providerPlugin.name, error: errMsg(err) });
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
if (!upstream) upstream = createUpstreamClient({ hooks: upstreamHooks });
|
|
60
|
-
} else {
|
|
61
|
-
const opencodeClient = createUpstreamClient({ hooks: upstreamHooks });
|
|
62
|
-
const opencodeModels = createModelsService({
|
|
63
|
-
baseUrl,
|
|
64
|
-
headers: opencodeClient.headers,
|
|
65
|
-
refreshMs: refreshIntervalMs(),
|
|
66
|
-
cacheFile: join(logDir(), "models.json"),
|
|
67
|
-
});
|
|
68
|
-
providers.push(createOpenCodeProvider({ upstream: opencodeClient, modelsService: opencodeModels }));
|
|
69
|
-
const orKeys = loadProviderKeys("openrouter");
|
|
70
|
-
if (orKeys.length) {
|
|
71
|
-
const { createOpenRouterProvider } = await import("../providers/openrouter.js");
|
|
72
|
-
providers.push(createOpenRouterProvider({ apiKeys: orKeys }));
|
|
73
|
-
console.log(`provider enabled: openrouter (${orKeys.length} key${orKeys.length > 1 ? "s" : ""})`);
|
|
74
|
-
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "openrouter", keys: orKeys.length });
|
|
75
|
-
}
|
|
76
|
-
const genericConfigs = loadProviderConfigs();
|
|
77
|
-
for (const [gid, cfg] of Object.entries(genericConfigs)) {
|
|
78
|
-
if (gid === "opencode" || gid === "openrouter") continue;
|
|
79
|
-
const base = String(cfg?.baseUrl || "").trim();
|
|
80
|
-
const keys = Array.isArray(cfg?.keys) ? cfg.keys.filter((k) => typeof k === "string" && k.trim()) : [];
|
|
81
|
-
const auths = Array.isArray(cfg?.auths) ? cfg.auths : [];
|
|
82
|
-
// 可扩展:优先走注册表定制 provider(如 workbuddy、cline),新增供应商仅需在 registry.js 注册
|
|
83
|
-
const { getCustomProviderFactory } = await import("../providers/registry.js");
|
|
84
|
-
const customFactory = await getCustomProviderFactory(gid, base);
|
|
85
|
-
if (customFactory) {
|
|
86
|
-
if (gid === "workbuddy" && !keys.length) continue;
|
|
87
|
-
if (gid !== "workbuddy" && (!base || !keys.length)) continue;
|
|
88
|
-
try {
|
|
89
|
-
const provider = gid === "workbuddy"
|
|
90
|
-
? await customFactory({ baseUrl: base || "https://copilot.tencent.com", apiKeys: keys, auths })
|
|
91
|
-
: await customFactory({ id: gid, baseUrl: base, apiKeys: keys });
|
|
92
|
-
providers.push(provider);
|
|
93
|
-
console.log(`provider enabled: ${gid} (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base || provider.baseUrl} [custom]`);
|
|
94
|
-
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base || provider.baseUrl });
|
|
95
|
-
} catch (err) {
|
|
96
|
-
console.log(`provider ${gid} failed: ${err?.message || err}`);
|
|
97
|
-
appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
|
|
98
|
-
}
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
if (!base || !keys.length) continue;
|
|
102
|
-
try {
|
|
103
|
-
const { createGenericProvider } = await import("../providers/generic.js");
|
|
104
|
-
providers.push(createGenericProvider({ id: gid, baseUrl: base, apiKeys: keys }));
|
|
105
|
-
console.log(`provider enabled: ${gid} (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base}`);
|
|
106
|
-
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base });
|
|
107
|
-
} catch (err) {
|
|
108
|
-
console.log(`provider ${gid} failed: ${err?.message || err}`);
|
|
109
|
-
appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
if (!genericConfigs["workbuddy"]) {
|
|
113
|
-
const wbKeys = loadProviderKeys("workbuddy");
|
|
114
|
-
if (wbKeys.length) {
|
|
115
|
-
try {
|
|
116
|
-
const { createWorkbuddyProvider } = await import("../providers/workbuddy.js");
|
|
117
|
-
const wbAuths = loadProviderAuths("workbuddy");
|
|
118
|
-
providers.push(createWorkbuddyProvider({ apiKeys: wbKeys, auths: wbAuths }));
|
|
119
|
-
console.log(`provider enabled: workbuddy (${wbKeys.length} key${wbKeys.length > 1 ? "s" : ""}) baseUrl=https://copilot.tencent.com (env)`);
|
|
120
|
-
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "workbuddy", keys: wbKeys.length, baseUrl: "https://copilot.tencent.com" });
|
|
121
|
-
} catch (err) {
|
|
122
|
-
console.log(`provider workbuddy failed: ${err?.message || err}`);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
const { createProviderDispatcher } = await import("../providers/dispatcher.js");
|
|
127
|
-
upstream = createProviderDispatcher(providers);
|
|
128
|
-
appendEvent({ ts: Date.now(), type: "providers", providers: providers.map((p) => p.id) });
|
|
129
|
-
}
|
|
130
|
-
const opencodeProvider = providers.find((p) => p.id === "opencode");
|
|
131
|
-
const models = createModelsService({
|
|
132
|
-
providers: providers.length > 1 ? providers : undefined,
|
|
133
|
-
baseUrl,
|
|
134
|
-
headers: upstream.headers || opencodeProvider?.upstream?.headers,
|
|
135
|
-
refreshMs: refreshIntervalMs(),
|
|
136
|
-
cacheFile: join(logDir(), "models.json"),
|
|
137
|
-
});
|
|
138
|
-
const auto = createAutoSelector({
|
|
139
|
-
cooldownMs: modelCooldownMs(),
|
|
140
|
-
slowCooldownMs: slowCooldownMs(),
|
|
141
|
-
file: defaultStateFile(),
|
|
142
|
-
loadCandidates: async () => {
|
|
143
|
-
try {
|
|
144
|
-
return (await models.get()).data.map((m) => m.id);
|
|
145
|
-
} catch {
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
},
|
|
149
|
-
});
|
|
150
|
-
const peers = createPeersService({ cooldownMs: peerCooldownMs(), heatMs: peerHeatMs() });
|
|
151
|
-
const groups = createGroupsService({});
|
|
152
|
-
const bans = createBansService({ windowMs: banWindowMs(), threshold: banThreshold() });
|
|
153
|
-
|
|
154
|
-
const isDebug = process.env.MSLXDFF_DEBUG === "1";
|
|
155
|
-
const bus = createEventBus();
|
|
156
|
-
|
|
157
|
-
try {
|
|
158
|
-
const { loadModelAliases } = await import("../providers/model-id.js");
|
|
159
|
-
loadModelAliases();
|
|
160
|
-
} catch {}
|
|
161
|
-
|
|
162
|
-
const router = createRouter({ token, upstream, models, auto, logs, peers, maxHops: maxHopsValue(), groups, bans, bus, plugins: loadedPlugins });
|
|
163
|
-
const listenHost = effectiveHost();
|
|
164
|
-
const srv = startServer({
|
|
165
|
-
router,
|
|
166
|
-
signals: !isDebug,
|
|
167
|
-
host: listenHost,
|
|
168
|
-
onBeforeClose: loadedPlugins.length
|
|
169
|
-
? () => runHook(loadedPlugins, "server:stop", { version: VERSION }).then(() => {})
|
|
170
|
-
: undefined,
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
if (isDebug) {
|
|
174
|
-
bus.subscribe((e) => {
|
|
175
|
-
try {
|
|
176
|
-
console.log(fmtEvent(e));
|
|
177
|
-
} catch {}
|
|
178
|
-
});
|
|
179
|
-
const { startDaemon: sd } = await import("../daemon.js");
|
|
180
|
-
const restore2 = () => {
|
|
181
|
-
console.log("\n[debug] restoring background daemon...");
|
|
182
|
-
try {
|
|
183
|
-
const restoredPid = sd([]);
|
|
184
|
-
console.log(`[debug] daemon restored (pid ${restoredPid})`);
|
|
185
|
-
} catch (err) {
|
|
186
|
-
console.error(`[debug] could not restore daemon: ${err.message}`);
|
|
187
|
-
}
|
|
188
|
-
setTimeout(() => process.exit(0), 300);
|
|
189
|
-
};
|
|
190
|
-
process.on("SIGINT", restore2);
|
|
191
|
-
process.on("SIGTERM", restore2);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Robust ready: if EADDRINUSE (bare daemon still holds port), kill holders and retry once
|
|
195
|
-
try {
|
|
196
|
-
await srv.ready();
|
|
197
|
-
} catch (err) {
|
|
198
|
-
const msg = String(err?.message || err);
|
|
199
|
-
const code = err?.code || "";
|
|
200
|
-
if (code === "EADDRINUSE" || msg.includes("EADDRINUSE")) {
|
|
201
|
-
console.log(`port ${resolvePort()} in use — freeing stale holder and retrying...`);
|
|
202
|
-
try {
|
|
203
|
-
const { execFile } = await import("node:child_process");
|
|
204
|
-
const execAsync2 = (f, a) => new Promise((res) => execFile(f, a, { windowsHide: true, timeout: 4000 }, (e, so, se) => res({ e, so: String(so||""), se: String(se||"") })));
|
|
205
|
-
const port = resolvePort();
|
|
206
|
-
// kill via ss parse (same as autostart)
|
|
207
|
-
const ss1 = await execAsync2("ss", ["-lptn", `sport = :${port}`]);
|
|
208
|
-
const out = ss1.so || "";
|
|
209
|
-
const pids = new Set();
|
|
210
|
-
let m;
|
|
211
|
-
const re = /pid=(\d+)/g;
|
|
212
|
-
while ((m = re.exec(out))) pids.add(Number(m[1]));
|
|
213
|
-
if (!pids.size) {
|
|
214
|
-
const ss2 = await execAsync2("ss", ["-lptn"]);
|
|
215
|
-
for (const line of (ss2.so||"").split("\n")) {
|
|
216
|
-
if (!line.includes(`:${port}`)) continue;
|
|
217
|
-
let m2; const re2 = /pid=(\d+)/g;
|
|
218
|
-
while ((m2 = re2.exec(line))) pids.add(Number(m2[1]));
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
for (const p of pids) { if (p !== process.pid) try { process.kill(p, "SIGTERM"); } catch {} }
|
|
222
|
-
if (pids.size) await new Promise((r2) => setTimeout(r2, 600));
|
|
223
|
-
for (const p of pids) try { const { isPidAlive } = await import("../daemon.js"); if (isPidAlive(p)) process.kill(p, "SIGKILL"); } catch {}
|
|
224
|
-
try { await execAsync2("fuser", ["-k", `${port}/tcp`]); } catch {}
|
|
225
|
-
for (let i=0;i<10;i++) {
|
|
226
|
-
const chk = await execAsync2("ss", ["-ltn"]);
|
|
227
|
-
if (!chk.so.includes(`:${port}`)) break;
|
|
228
|
-
await new Promise((r2)=>setTimeout(r2,200));
|
|
229
|
-
}
|
|
230
|
-
} catch {}
|
|
231
|
-
await srv.ready();
|
|
232
|
-
} else throw err;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (loadedPlugins.length) {
|
|
236
|
-
runHook(loadedPlugins, "server:start", { port: srv.server.address()?.port, host: listenHost, version: VERSION }).catch(() => {});
|
|
237
|
-
const eventPlugins = loadedPlugins.filter((p) => typeof p.onEvent === "function");
|
|
238
|
-
if (eventPlugins.length) {
|
|
239
|
-
bus.subscribe((e) => {
|
|
240
|
-
for (const p of eventPlugins) {
|
|
241
|
-
try { p.onEvent(e); } catch {}
|
|
242
|
-
}
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
setTimeout(() => {
|
|
248
|
-
upstream.preheat().then((r) => {
|
|
249
|
-
const entry = { ts: Date.now(), type: "upstream-preheat", ...r, baseUrl };
|
|
250
|
-
try { bus.emit(entry); } catch {}
|
|
251
|
-
try { logs.appendEvent(entry); } catch {}
|
|
252
|
-
if (r.skipped) console.log(`[preheat] skipped (MSLXDFF_PREHEAT disabled)`);
|
|
253
|
-
else if (r.ok) console.log(`[preheat] opencode models ok ${r.status} ${r.ms}ms`);
|
|
254
|
-
else console.log(`[preheat] opencode models failed ${r.error || r.status || ""} ${r.ms || 0}ms`);
|
|
255
|
-
}).catch(() => {});
|
|
256
|
-
}, 100).unref?.();
|
|
257
|
-
|
|
258
|
-
models.startAutoRefresh();
|
|
259
|
-
if (process.env.MSLXDFF_DAEMON) {
|
|
260
|
-
writePid(process.pid, VERSION);
|
|
261
|
-
}
|
|
262
|
-
const addr = srv.server.address();
|
|
263
|
-
const host = addr.address === "0.0.0.0" || addr.address === "::" ? "localhost" : addr.address;
|
|
264
|
-
console.log(`mslxdff v${VERSION} listening on http://${host}:${addr.port}`);
|
|
265
|
-
if (created) {
|
|
266
|
-
console.log(`auth token: ${token}`);
|
|
267
|
-
}
|
|
268
|
-
console.log(`endpoint: http://${host}:${addr.port}/v1`);
|
|
269
|
-
try {
|
|
270
|
-
const { hedgeDelayMs } = await import("../routes/hedge.js");
|
|
271
|
-
const hd = hedgeDelayMs();
|
|
272
|
-
console.log(`hedge: ${hd ? `${hd}ms` : "off"} (MSLXDFF_HEDGE_DELAY_MS)`);
|
|
273
|
-
} catch {}
|
|
274
|
-
|
|
275
|
-
// best-effort: ensure autostart on Linux (so daemon survives reboot/SSH disconnect without manual cmd)
|
|
276
|
-
if (process.platform === "linux" && !process.env.MSLXDFF_NO_AUTOSTART) {
|
|
277
|
-
setTimeout(async () => {
|
|
278
|
-
try {
|
|
279
|
-
const { getAutostartStatus, enableAutostart } = await import("../autostart.js");
|
|
280
|
-
const st = await getAutostartStatus();
|
|
281
|
-
if (!st.enabled) {
|
|
282
|
-
const r = await enableAutostart();
|
|
283
|
-
if (r.ok) {
|
|
284
|
-
console.log(`autostart auto-enabled: ${r.method}${r.linger ? ` linger=${r.linger}` : ""}`);
|
|
285
|
-
try { bus?.emit({ ts: Date.now(), type: "autostart-auto-enabled", method: r.method }); } catch {}
|
|
286
|
-
try { appendEvent({ ts: Date.now(), type: "autostart-auto-enabled", method: r.method }); } catch {}
|
|
287
|
-
} else {
|
|
288
|
-
console.log(`autostart auto-enable failed: ${r.error || "unknown"} (run mslxdff -enable-autostart manually)`);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
} catch {}
|
|
292
|
-
}, 2500).unref?.();
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// group sync
|
|
296
|
-
const { syncAllJoinedGroups } = await import("../cli/group-helpers.js");
|
|
297
|
-
syncAllJoinedGroups({ peers, groups })
|
|
298
|
-
.then((results) => {
|
|
299
|
-
for (const r of results) {
|
|
300
|
-
if (r.error) console.log(`group sync ${r.name}: failed — ${r.error}`);
|
|
301
|
-
else console.log(`group sync ${r.name}: ${r.total} member(s), ${r.added} peer(s)`);
|
|
302
|
-
}
|
|
303
|
-
})
|
|
304
|
-
.catch((err) => console.log(`group sync: ${errMsg(err)}`));
|
|
305
|
-
const groupSyncTimer = setInterval(() => {
|
|
306
|
-
syncAllJoinedGroups({ peers, groups })
|
|
307
|
-
.then((results) => {
|
|
308
|
-
for (const r of results) {
|
|
309
|
-
if (r.error) console.log(`group sync ${r.name}: failed — ${r.error}`);
|
|
310
|
-
else if (r.added) console.log(`group sync ${r.name}: ${r.total} member(s), ${r.added} peer(s)`);
|
|
311
|
-
}
|
|
312
|
-
})
|
|
313
|
-
.catch((err) => console.log(`group sync: ${errMsg(err)}`));
|
|
314
|
-
}, groupSyncIntervalMs());
|
|
315
|
-
groupSyncTimer.unref();
|
|
316
|
-
|
|
317
|
-
const broadbandGroups = () => loadGroupsJoined().filter((g) => g.kind === "broadband" && g.leaderUrl);
|
|
318
|
-
if (broadbandGroups().length) {
|
|
319
|
-
const streamEnabled = (() => {
|
|
320
|
-
const v = process.env.MSLXDFF_BROADBAND_STREAM;
|
|
321
|
-
if (v === "0" || v === "false" || v === "off") return false;
|
|
322
|
-
return true;
|
|
323
|
-
})();
|
|
324
|
-
const execAndPost = async (g, reqId, body) => {
|
|
325
|
-
let result;
|
|
326
|
-
try {
|
|
327
|
-
const upRes = await upstream.chat(body);
|
|
328
|
-
const ct = upRes.headers.get("content-type") || "";
|
|
329
|
-
const isStream = Boolean(body?.stream) || ct.includes("text/event-stream");
|
|
330
|
-
if (isStream && upRes.body) {
|
|
331
|
-
let collected = "";
|
|
332
|
-
for await (const chunk of upRes.body) {
|
|
333
|
-
if (typeof chunk === "string") collected += chunk;
|
|
334
|
-
else if (Buffer.isBuffer(chunk)) collected += chunk.toString("utf8");
|
|
335
|
-
else if (chunk instanceof Uint8Array) collected += Buffer.from(chunk).toString("utf8");
|
|
336
|
-
else collected += String(chunk);
|
|
337
|
-
}
|
|
338
|
-
result = { status: upRes.status, headers: { "Content-Type": "text/event-stream" }, body: collected };
|
|
339
|
-
} else {
|
|
340
|
-
const txt = await upRes.text();
|
|
341
|
-
let parsed;
|
|
342
|
-
try { parsed = JSON.parse(txt); } catch { parsed = txt; }
|
|
343
|
-
result = { status: upRes.status, headers: { "Content-Type": upRes.headers.get("content-type") || "application/json" }, body: typeof parsed === "string" ? parsed : JSON.stringify(parsed) };
|
|
344
|
-
}
|
|
345
|
-
} catch (err) {
|
|
346
|
-
result = { status: 502, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ error: errMsg(err) }) };
|
|
347
|
-
}
|
|
348
|
-
try {
|
|
349
|
-
await fetch(`${g.leaderUrl}/v1/groups/relay/result`, {
|
|
350
|
-
method: "POST",
|
|
351
|
-
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
352
|
-
body: JSON.stringify({ name: g.name, group: g.name, reqId, result }),
|
|
353
|
-
signal: AbortSignal.timeout(5000),
|
|
354
|
-
});
|
|
355
|
-
} catch {}
|
|
356
|
-
};
|
|
357
|
-
const doHeartbeat = async () => {
|
|
358
|
-
for (const g of broadbandGroups()) {
|
|
359
|
-
try {
|
|
360
|
-
const res = await fetch(`${g.leaderUrl}/v1/groups/relay/heartbeat`, {
|
|
361
|
-
method: "POST",
|
|
362
|
-
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
363
|
-
body: JSON.stringify({ name: g.name, group: g.name }),
|
|
364
|
-
signal: AbortSignal.timeout(5000),
|
|
365
|
-
});
|
|
366
|
-
if (!res.ok) {
|
|
367
|
-
const txt = await res.text().catch(() => "");
|
|
368
|
-
console.log(`broadband heartbeat ${g.name}: ${res.status} ${txt.slice(0, 100)}`);
|
|
369
|
-
}
|
|
370
|
-
} catch (err) {
|
|
371
|
-
console.log(`broadband heartbeat ${g.name}: failed — ${errMsg(err)}`);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
};
|
|
375
|
-
const doPoll = async () => {
|
|
376
|
-
for (const g of broadbandGroups()) {
|
|
377
|
-
try {
|
|
378
|
-
const pollRes = await fetch(`${g.leaderUrl}/v1/groups/relay/poll`, {
|
|
379
|
-
method: "POST",
|
|
380
|
-
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
381
|
-
body: JSON.stringify({ name: g.name, group: g.name }),
|
|
382
|
-
signal: AbortSignal.timeout(8000),
|
|
383
|
-
});
|
|
384
|
-
if (!pollRes.ok) continue;
|
|
385
|
-
const data = await pollRes.json().catch(() => ({}));
|
|
386
|
-
const items = data.data || [];
|
|
387
|
-
for (const item of items) {
|
|
388
|
-
const { reqId, body } = item;
|
|
389
|
-
await execAndPost(g, reqId, body);
|
|
390
|
-
}
|
|
391
|
-
} catch {}
|
|
392
|
-
}
|
|
393
|
-
};
|
|
394
|
-
if (!streamEnabled) {
|
|
395
|
-
doHeartbeat().catch(() => {});
|
|
396
|
-
const hbTimer = setInterval(doHeartbeat, 30_000);
|
|
397
|
-
hbTimer.unref();
|
|
398
|
-
const pollTimer = setInterval(doPoll, 1000);
|
|
399
|
-
pollTimer.unref();
|
|
400
|
-
console.log(`broadband relay: heartbeat 30s + poll 1s for ${broadbandGroups().length} group(s) [poll mode]`);
|
|
401
|
-
} else {
|
|
402
|
-
const streamManagers = new Map();
|
|
403
|
-
const startStreamForGroup = (g) => {
|
|
404
|
-
if (streamManagers.has(g.name)) return;
|
|
405
|
-
let attempts = 0;
|
|
406
|
-
let abort = null;
|
|
407
|
-
let stopped = false;
|
|
408
|
-
const connect = async () => {
|
|
409
|
-
if (stopped) return;
|
|
410
|
-
const url = `${g.leaderUrl}/v1/groups/relay/stream?name=${encodeURIComponent(g.name)}`;
|
|
411
|
-
const controller = new AbortController();
|
|
412
|
-
abort = controller;
|
|
413
|
-
try {
|
|
414
|
-
const res = await fetch(url, {
|
|
415
|
-
headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
|
|
416
|
-
signal: controller.signal,
|
|
417
|
-
});
|
|
418
|
-
if (!res.ok) throw new Error(`stream ${res.status}`);
|
|
419
|
-
if (!res.body) throw new Error("no body");
|
|
420
|
-
attempts = 0;
|
|
421
|
-
let buf = "";
|
|
422
|
-
const decodeChunk = (c) => {
|
|
423
|
-
if (typeof c === "string") return c;
|
|
424
|
-
if (Buffer.isBuffer(c)) return c.toString("utf8");
|
|
425
|
-
if (c instanceof Uint8Array) return Buffer.from(c).toString("utf8");
|
|
426
|
-
return String(c);
|
|
427
|
-
};
|
|
428
|
-
for await (const chunk of res.body) {
|
|
429
|
-
buf += decodeChunk(chunk);
|
|
430
|
-
let idx;
|
|
431
|
-
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
432
|
-
const raw = buf.slice(0, idx);
|
|
433
|
-
buf = buf.slice(idx + 2);
|
|
434
|
-
if (!raw || raw.startsWith(":")) continue;
|
|
435
|
-
let event = "message";
|
|
436
|
-
let data = "";
|
|
437
|
-
for (const line of raw.split("\n")) {
|
|
438
|
-
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
439
|
-
else if (line.startsWith("data:")) data += line.slice(5).trim();
|
|
440
|
-
}
|
|
441
|
-
if (event === "relay" && data) {
|
|
442
|
-
try {
|
|
443
|
-
const parsed = JSON.parse(data);
|
|
444
|
-
const { reqId, body } = parsed;
|
|
445
|
-
if (reqId && body) execAndPost(g, reqId, body).catch(() => {});
|
|
446
|
-
} catch {}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
throw new Error("stream ended");
|
|
451
|
-
} catch (err) {
|
|
452
|
-
if (stopped) return;
|
|
453
|
-
const msg = errMsg(err);
|
|
454
|
-
if (!String(msg).includes("abort") && !String(msg).includes("Abort")) console.log(`broadband stream ${g.name}: ${msg} — reconnecting`);
|
|
455
|
-
attempts++;
|
|
456
|
-
const delay = Math.min(30_000, 1000 * Math.pow(2, attempts - 1) + Math.random() * 500);
|
|
457
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
458
|
-
connect();
|
|
459
|
-
}
|
|
460
|
-
};
|
|
461
|
-
streamManagers.set(g.name, { stop: () => { stopped = true; abort?.abort(); } });
|
|
462
|
-
connect();
|
|
463
|
-
};
|
|
464
|
-
for (const g of broadbandGroups()) startStreamForGroup(g);
|
|
465
|
-
const ensureTimer = setInterval(() => {
|
|
466
|
-
for (const g of broadbandGroups()) if (!streamManagers.has(g.name)) startStreamForGroup(g);
|
|
467
|
-
}, 10_000);
|
|
468
|
-
ensureTimer.unref();
|
|
469
|
-
console.log(`broadband relay: stream (SSE) + ping 25s for ${broadbandGroups().length} group(s)`);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
14
|
+
const ctx = await setupProviders();
|
|
15
|
+
const { srv, bus } = await startServerLifecycle({ VERSION, ...ctx, logs });
|
|
16
|
+
void srv;
|
|
17
|
+
startGroupSync({ peers: ctx.peers, groups: ctx.groups });
|
|
18
|
+
startBroadband({ token: ctx.token, upstream: ctx.upstream });
|
|
472
19
|
|
|
473
20
|
const { setupAutoUpdate } = await import("./auto-update.js");
|
|
474
21
|
setupAutoUpdate({ VERSION, bus, logs });
|
|
475
22
|
}
|
|
476
|
-
|
|
477
|
-
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { errMsg } from "../cli/util.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 宽带 SSE 长连 — 单组成员订阅 + 指数退避重连 + ensure 定时器。
|
|
5
|
+
* execAndPost / broadbandGroups 由 broadband.js 注入。
|
|
6
|
+
*/
|
|
7
|
+
export function startBroadbandStream({ token, upstream, execAndPost, broadbandGroups }) {
|
|
8
|
+
const streamManagers = new Map();
|
|
9
|
+
const startStreamForGroup = (g) => {
|
|
10
|
+
if (streamManagers.has(g.name)) return;
|
|
11
|
+
let attempts = 0;
|
|
12
|
+
let abort = null;
|
|
13
|
+
let stopped = false;
|
|
14
|
+
const connect = async () => {
|
|
15
|
+
if (stopped) return;
|
|
16
|
+
const url = `${g.leaderUrl}/v1/groups/relay/stream?name=${encodeURIComponent(g.name)}`;
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
abort = controller;
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(url, {
|
|
21
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
|
|
22
|
+
signal: controller.signal,
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) throw new Error(`stream ${res.status}`);
|
|
25
|
+
if (!res.body) throw new Error("no body");
|
|
26
|
+
attempts = 0;
|
|
27
|
+
let buf = "";
|
|
28
|
+
const decodeChunk = (c) => {
|
|
29
|
+
if (typeof c === "string") return c;
|
|
30
|
+
if (Buffer.isBuffer(c)) return c.toString("utf8");
|
|
31
|
+
if (c instanceof Uint8Array) return Buffer.from(c).toString("utf8");
|
|
32
|
+
return String(c);
|
|
33
|
+
};
|
|
34
|
+
for await (const chunk of res.body) {
|
|
35
|
+
buf += decodeChunk(chunk);
|
|
36
|
+
let idx;
|
|
37
|
+
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
38
|
+
const raw = buf.slice(0, idx);
|
|
39
|
+
buf = buf.slice(idx + 2);
|
|
40
|
+
if (!raw || raw.startsWith(":")) continue;
|
|
41
|
+
let event = "message";
|
|
42
|
+
let data = "";
|
|
43
|
+
for (const line of raw.split("\n")) {
|
|
44
|
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
45
|
+
else if (line.startsWith("data:")) data += line.slice(5).trim();
|
|
46
|
+
}
|
|
47
|
+
if (event === "relay" && data) {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(data);
|
|
50
|
+
const { reqId, body } = parsed;
|
|
51
|
+
if (reqId && body) execAndPost(g, reqId, body).catch(() => {});
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw new Error("stream ended");
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (stopped) return;
|
|
59
|
+
const msg = errMsg(err);
|
|
60
|
+
if (!String(msg).includes("abort") && !String(msg).includes("Abort")) console.log(`broadband stream ${g.name}: ${msg} — reconnecting`);
|
|
61
|
+
attempts++;
|
|
62
|
+
const delay = Math.min(30_000, 1000 * Math.pow(2, attempts - 1) + Math.random() * 500);
|
|
63
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
64
|
+
connect();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
streamManagers.set(g.name, { stop: () => { stopped = true; abort?.abort(); } });
|
|
68
|
+
connect();
|
|
69
|
+
};
|
|
70
|
+
for (const g of broadbandGroups()) startStreamForGroup(g);
|
|
71
|
+
const ensureTimer = setInterval(() => {
|
|
72
|
+
for (const g of broadbandGroups()) if (!streamManagers.has(g.name)) startStreamForGroup(g);
|
|
73
|
+
}, 10_000);
|
|
74
|
+
ensureTimer.unref();
|
|
75
|
+
console.log(`broadband relay: stream (SSE) + ping 25s for ${broadbandGroups().length} group(s)`);
|
|
76
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { loadGroupsJoined } from "../state.js";
|
|
2
|
+
import { errMsg } from "../cli/util.js";
|
|
3
|
+
import { startBroadbandStream } from "./broadband-stream.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 宽带中继 — poll 模式(heartbeat + poll)与 stream 模式分发。
|
|
7
|
+
* stream 长连下沉 broadband-stream.js,本文件保留任务执行与降级分支。
|
|
8
|
+
*/
|
|
9
|
+
export function startBroadband({ token, upstream }) {
|
|
10
|
+
const broadbandGroups = () => loadGroupsJoined().filter((g) => g.kind === "broadband" && g.leaderUrl);
|
|
11
|
+
if (!broadbandGroups().length) return;
|
|
12
|
+
const streamEnabled = (() => {
|
|
13
|
+
const v = process.env.MSLXDFF_BROADBAND_STREAM;
|
|
14
|
+
if (v === "0" || v === "false" || v === "off") return false;
|
|
15
|
+
return true;
|
|
16
|
+
})();
|
|
17
|
+
const execAndPost = async (g, reqId, body) => {
|
|
18
|
+
let result;
|
|
19
|
+
try {
|
|
20
|
+
const upRes = await upstream.chat(body);
|
|
21
|
+
const ct = upRes.headers.get("content-type") || "";
|
|
22
|
+
const isStream = Boolean(body?.stream) || ct.includes("text/event-stream");
|
|
23
|
+
if (isStream && upRes.body) {
|
|
24
|
+
let collected = "";
|
|
25
|
+
for await (const chunk of upRes.body) {
|
|
26
|
+
if (typeof chunk === "string") collected += chunk;
|
|
27
|
+
else if (Buffer.isBuffer(chunk)) collected += chunk.toString("utf8");
|
|
28
|
+
else if (chunk instanceof Uint8Array) collected += Buffer.from(chunk).toString("utf8");
|
|
29
|
+
else collected += String(chunk);
|
|
30
|
+
}
|
|
31
|
+
result = { status: upRes.status, headers: { "Content-Type": "text/event-stream" }, body: collected };
|
|
32
|
+
} else {
|
|
33
|
+
const txt = await upRes.text();
|
|
34
|
+
let parsed;
|
|
35
|
+
try { parsed = JSON.parse(txt); } catch { parsed = txt; }
|
|
36
|
+
result = { status: upRes.status, headers: { "Content-Type": upRes.headers.get("content-type") || "application/json" }, body: typeof parsed === "string" ? parsed : JSON.stringify(parsed) };
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
result = { status: 502, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ error: errMsg(err) }) };
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
await fetch(`${g.leaderUrl}/v1/groups/relay/result`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
45
|
+
body: JSON.stringify({ name: g.name, group: g.name, reqId, result }),
|
|
46
|
+
signal: AbortSignal.timeout(5000),
|
|
47
|
+
});
|
|
48
|
+
} catch {}
|
|
49
|
+
};
|
|
50
|
+
const doHeartbeat = async () => {
|
|
51
|
+
for (const g of broadbandGroups()) {
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`${g.leaderUrl}/v1/groups/relay/heartbeat`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
56
|
+
body: JSON.stringify({ name: g.name, group: g.name }),
|
|
57
|
+
signal: AbortSignal.timeout(5000),
|
|
58
|
+
});
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
const txt = await res.text().catch(() => "");
|
|
61
|
+
console.log(`broadband heartbeat ${g.name}: ${res.status} ${txt.slice(0, 100)}`);
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.log(`broadband heartbeat ${g.name}: failed — ${errMsg(err)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const doPoll = async () => {
|
|
69
|
+
for (const g of broadbandGroups()) {
|
|
70
|
+
try {
|
|
71
|
+
const pollRes = await fetch(`${g.leaderUrl}/v1/groups/relay/poll`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
74
|
+
body: JSON.stringify({ name: g.name, group: g.name }),
|
|
75
|
+
signal: AbortSignal.timeout(8000),
|
|
76
|
+
});
|
|
77
|
+
if (!pollRes.ok) continue;
|
|
78
|
+
const data = await pollRes.json().catch(() => ({}));
|
|
79
|
+
const items = data.data || [];
|
|
80
|
+
for (const item of items) {
|
|
81
|
+
const { reqId, body } = item;
|
|
82
|
+
await execAndPost(g, reqId, body);
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
if (!streamEnabled) {
|
|
88
|
+
doHeartbeat().catch(() => {});
|
|
89
|
+
const hbTimer = setInterval(doHeartbeat, 30_000);
|
|
90
|
+
hbTimer.unref();
|
|
91
|
+
const pollTimer = setInterval(doPoll, 1000);
|
|
92
|
+
pollTimer.unref();
|
|
93
|
+
console.log(`broadband relay: heartbeat 30s + poll 1s for ${broadbandGroups().length} group(s) [poll mode]`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
startBroadbandStream({ token, upstream, execAndPost, broadbandGroups });
|
|
97
|
+
}
|