@sleep2agi/commhub-server 0.6.0 → 0.8.0-preview.0

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/src/index.ts CHANGED
@@ -3,12 +3,26 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
3
3
  import { z } from "zod/v4";
4
4
  import { registerTools } from "./tools.js";
5
5
  import { db, logTaskEvent, logAudit } from "./db.js";
6
- import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.js";
7
- import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
6
+ import { createSSEStream, pushEvent, getSSEStats } from "./push.js";
7
+ import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
8
8
 
9
9
  const PORT = Number(process.env.PORT) || 9200;
10
- const HOST = process.env.HOST || "0.0.0.0";
10
+ const HOST = process.env.HOST || "127.0.0.1";
11
11
  const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
12
+ const DEV_OPEN = process.argv.includes("--dev-open") || process.env.COMMHUB_DEV_OPEN === "1";
13
+ const TMUX_ENABLED = process.env.COMMHUB_ENABLE_TMUX === "1";
14
+ const SECURITY_LABEL = DEV_OPEN ? "⚠️ DEV OPEN MODE" : "🔒 secured";
15
+ const TMUX_ALLOWLIST = new Set(
16
+ (process.env.COMMHUB_TMUX_ALLOWLIST || "")
17
+ .split(",")
18
+ .map((s) => s.trim())
19
+ .filter(Boolean)
20
+ );
21
+ let masterTokenDeprecationLogged = false;
22
+
23
+ if (AUTH_TOKEN) {
24
+ console.warn("[commhub] COMMHUB_AUTH_TOKEN is deprecated and will be removed in v1.0. See RFC-001.");
25
+ }
12
26
 
13
27
  // Read version from package.json so banners and /health stay in sync.
14
28
  const SERVER_VERSION = (() => {
@@ -70,10 +84,19 @@ function createServer(clientIP?: string, enforceNetworkId?: string | null, enfor
70
84
  }
71
85
 
72
86
  // ── Auth helper ─────────────────────────────────────
73
- function requireAuth(req: Request): Response | null {
87
+ function requestToken(req: Request): string {
74
88
  const header = req.headers.get("Authorization")?.replace("Bearer ", "");
75
89
  const url = new URL(req.url);
76
- const token = header || url.searchParams.get("token") || "";
90
+ return header || url.searchParams.get("token") || "";
91
+ }
92
+
93
+ function isLegacyAuthToken(req: Request): boolean {
94
+ const token = requestToken(req);
95
+ return !!AUTH_TOKEN && token === AUTH_TOKEN;
96
+ }
97
+
98
+ function requireAuth(req: Request): Response | null {
99
+ const token = requestToken(req);
77
100
 
78
101
  // V3: check api_tokens first
79
102
  if (token) {
@@ -82,17 +105,55 @@ function requireAuth(req: Request): Response | null {
82
105
  }
83
106
 
84
107
  // Legacy: check global COMMHUB_AUTH_TOKEN
85
- if (!AUTH_TOKEN) return null; // no token = open mode (dev)
86
- if (token === AUTH_TOKEN) return null;
108
+ if (!AUTH_TOKEN && DEV_OPEN) return null; // explicit local/dev open mode
109
+ if (token === AUTH_TOKEN) {
110
+ const u = new URL(req.url);
111
+ const readOnlyApi = u.pathname.startsWith("/api/") && (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS");
112
+ if (!readOnlyApi) return Response.json({ ok: false, error: "master-token auth is deprecated; use admin utok_" }, { status: 401 });
113
+ if (!masterTokenDeprecationLogged) {
114
+ console.warn("[commhub] master-token auth is deprecated and will be removed in v1.0. See RFC-001.");
115
+ masterTokenDeprecationLogged = true;
116
+ }
117
+ return null;
118
+ }
87
119
 
88
120
  return Response.json({ error: "unauthorized" }, { status: 401 });
89
121
  }
90
122
 
123
+ function getClientIP(req: Request, server?: any): string {
124
+ const direct = server?.requestIP?.(req)?.address;
125
+ if (direct) return direct;
126
+ const fwd = req.headers.get("x-forwarded-for");
127
+ return fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
128
+ }
129
+
130
+ function isLocalhostIP(ip: string): boolean {
131
+ return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1" || ip === "localhost";
132
+ }
133
+
134
+ function isTmuxAllowedIP(ip: string): boolean {
135
+ return isLocalhostIP(ip) || TMUX_ALLOWLIST.has(ip);
136
+ }
137
+
138
+ function requireAdminAuth(req: Request): Response | null {
139
+ const token = requestToken(req);
140
+ if (!token) return Response.json({ ok: false, error: "auth required" }, { status: 401 });
141
+ const resolved = resolveToken(token);
142
+ if (!resolved) return Response.json({ ok: false, error: "invalid token" }, { status: 401 });
143
+ if (resolved.user.role !== "admin") return Response.json({ ok: false, error: "admin required" }, { status: 403 });
144
+ return null;
145
+ }
146
+
147
+ function requireTmuxAccess(req: Request, server?: any): Response | null {
148
+ if (!TMUX_ENABLED) return Response.json({ ok: false, error: "tmux disabled" }, { status: 404 });
149
+ const ip = getClientIP(req, server);
150
+ if (!isTmuxAllowedIP(ip)) return Response.json({ ok: false, error: "tmux access denied from this ip" }, { status: 403 });
151
+ return requireAdminAuth(req);
152
+ }
153
+
91
154
  // Extract user + network + token-binding identity from request token.
92
155
  function resolveRequestAuth(req: Request): { userId: string; networkId: string | null; username: string; tokenName: string | null } | null {
93
- const header = req.headers.get("Authorization")?.replace("Bearer ", "");
94
- const url = new URL(req.url);
95
- const token = header || url.searchParams.get("token") || "";
156
+ const token = requestToken(req);
96
157
  if (!token) return null;
97
158
  const resolved = resolveToken(token);
98
159
  if (!resolved) return null;
@@ -243,17 +304,16 @@ Bun.serve({
243
304
  // ── WebSocket: tmux terminal ──
244
305
  const wsMatch = url.pathname.match(/^\/ws\/tmux\/([a-zA-Z0-9_-]+)$/);
245
306
  if (wsMatch) {
246
- const authErr = requireAuth(req);
247
- if (authErr) return withCors(req, authErr);
248
- if (server.upgrade(req, { data: { tmuxName: wsMatch[1] } })) return;
307
+ const tmuxErr = requireTmuxAccess(req, server);
308
+ if (tmuxErr) return withCors(req, tmuxErr);
309
+ if (server.upgrade(req, { data: { tmuxName: wsMatch[1] } } as any)) return;
249
310
  }
250
311
 
251
312
  // ── MCP Streamable HTTP endpoint ──
252
313
  if (url.pathname === "/mcp") {
253
314
  const authErr = requireAuth(req);
254
315
  if (authErr) return withCors(req, authErr);
255
- const fwd = req.headers.get("x-forwarded-for");
256
- const clientIP = fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
316
+ const clientIP = getClientIP(req, server);
257
317
  // V3: resolve token → enforce network_id in all MCP tools.
258
318
  // utok_ (user token, not network-bound) is allowed — the tool layer
259
319
  // scopes to the user's accessible networks. Without this Dashboard
@@ -268,11 +328,11 @@ Bun.serve({
268
328
  const transport = new WebStandardStreamableHTTPServerTransport({
269
329
  sessionIdGenerator: undefined,
270
330
  });
271
- const server = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias);
272
- await server.connect(transport);
331
+ const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias);
332
+ await mcpServer.connect(transport);
273
333
  const response = await transport.handleRequest(req);
274
334
  // Disconnect after response to prevent McpServer leak
275
- setImmediate(() => server.close().catch(() => {}));
335
+ setImmediate(() => mcpServer.close().catch(() => {}));
276
336
  return response;
277
337
  }
278
338
 
@@ -283,7 +343,31 @@ Bun.serve({
283
343
  const authErr = requireAuth(req);
284
344
  if (authErr) return authErr;
285
345
  const sessionName = decodeURIComponent(eventsMatch[1]);
286
- return createSSEStream(sessionName);
346
+ const authCtx = resolveRequestAuth(req);
347
+ const scopedNetId = authCtx?.networkId || url.searchParams.get("network_id");
348
+ if (!authCtx && isLegacyAuthToken(req)) {
349
+ if (scopedNetId) {
350
+ const session = db.get<any>(
351
+ "SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
352
+ sessionName, scopedNetId
353
+ );
354
+ if (!session) return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
355
+ }
356
+ return createSSEStream(sessionName, scopedNetId);
357
+ }
358
+ if (!authCtx || !scopedNetId) {
359
+ return withCors(req, Response.json({ ok: false, error: "network-scoped token required for SSE" }, { status: 403 }));
360
+ }
361
+ const role = getUserNetworkRole(authCtx.userId, scopedNetId);
362
+ if (!role) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
363
+ const session = db.get<any>(
364
+ "SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
365
+ sessionName, scopedNetId
366
+ );
367
+ if (!session && authCtx.networkId !== scopedNetId) {
368
+ return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
369
+ }
370
+ return createSSEStream(sessionName, scopedNetId);
287
371
  }
288
372
 
289
373
  // ── V3: License endpoints ──
@@ -343,14 +427,14 @@ Bun.serve({
343
427
  if (url.pathname === "/api/auth/login" && req.method === "POST") {
344
428
  const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
345
429
  if (!checkRateLimit(clientIP, 10)) {
346
- logAudit(null, null, "login_rate_limited", "auth", null, clientIP);
430
+ logAudit(null, null, "login_rate_limited", "auth", undefined, clientIP);
347
431
  return withCors(req, Response.json({ ok: false, error: "too many attempts, try again later" }, { status: 429 }));
348
432
  }
349
433
  try {
350
434
  const body = await req.json() as any;
351
435
  const result = login(body.username, body.password);
352
436
  if (result.ok) logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
353
- else logAudit(null, body.username, "login_failed", "user", null, "invalid credentials");
437
+ else logAudit(null, body.username, "login_failed", "user", undefined, "invalid credentials");
354
438
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
355
439
  } catch (e: any) {
356
440
  return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
@@ -397,9 +481,14 @@ Bun.serve({
397
481
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
398
482
  try {
399
483
  const body = await req.json() as any;
400
- const result = changePassword(resolved.user.user_id, body.old_password, body.new_password);
401
- if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
402
- return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
484
+ const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId);
485
+ if (result.ok) {
486
+ const issued = issueUserToken(resolved.user.user_id, "password-change");
487
+ if (resolved.tokenId) revokeToken(resolved.user.user_id, resolved.tokenId);
488
+ logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
489
+ return withCors(req, Response.json({ ...result, token: issued.token, token_id: issued.token_id }));
490
+ }
491
+ return withCors(req, Response.json(result, { status: 400 }));
403
492
  } catch (e: any) {
404
493
  return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
405
494
  }
@@ -631,7 +720,9 @@ Bun.serve({
631
720
  sessions_count: count?.cnt ?? 0,
632
721
  sse_connections: sse.total,
633
722
  sse_sessions: sse.sessions,
634
- auth: AUTH_TOKEN ? "enabled" : "disabled",
723
+ auth: DEV_OPEN ? "dev-open" : "user-token",
724
+ security: DEV_OPEN ? "dev-open" : "secured",
725
+ tmux: TMUX_ENABLED ? "enabled" : "disabled",
635
726
  v3_auth: true,
636
727
  multi_network: true,
637
728
  license: license?.type || "none",
@@ -734,7 +825,7 @@ Bun.serve({
734
825
  let sessionSql = "SELECT 1 FROM sessions WHERE alias = ?1";
735
826
  if (taskNetId) { sessionSql += " AND network_id = ?2"; sessionParams.push(taskNetId); }
736
827
  const targetSession = db.get<any>(sessionSql, ...sessionParams);
737
- if (targetSession) pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession });
828
+ if (targetSession) pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession }, taskNetId);
738
829
  return withCors(req, Response.json({ ok: true, message_id: id }));
739
830
  }
740
831
 
@@ -773,13 +864,17 @@ Bun.serve({
773
864
  );
774
865
  ids.push(id);
775
866
  }
776
- pushBroadcast(targets.map(t => t.alias), { type: "broadcast", inbox_count: 1, message: body.message.slice(0, 200) });
867
+ for (const t of targets) {
868
+ pushEvent(t.alias, { type: "broadcast", inbox_count: 1 }, t.network_id);
869
+ }
777
870
  return withCors(req, Response.json({ ok: true, recipients: targets.length, message_ids: ids }));
778
871
  }
779
872
 
780
873
  // ── REST: tmux capture-pane ──
781
874
  const tmuxCapture = url.pathname.match(/^\/api\/tmux\/([a-zA-Z0-9_-]+)$/);
782
875
  if (tmuxCapture && req.method === "GET") {
876
+ const tmuxErr = requireTmuxAccess(req, server);
877
+ if (tmuxErr) return withCors(req, tmuxErr);
783
878
  const name = tmuxCapture[1];
784
879
  const lines = Number(url.searchParams.get("lines")) || 30;
785
880
  try {
@@ -802,6 +897,8 @@ Bun.serve({
802
897
  // ── REST: tmux send-keys ──
803
898
  const tmuxSend = url.pathname.match(/^\/api\/tmux\/([a-zA-Z0-9_-]+)\/send$/);
804
899
  if (tmuxSend && req.method === "POST") {
900
+ const tmuxErr = requireTmuxAccess(req, server);
901
+ if (tmuxErr) return withCors(req, tmuxErr);
805
902
  const name = tmuxSend[1];
806
903
  let body: { text?: string; enter?: boolean };
807
904
  try { body = await req.json(); } catch {
@@ -988,14 +1085,14 @@ Endpoints:
988
1085
  POST /mcp - MCP Streamable HTTP (for Claude Code / Codex)
989
1086
  GET /events/:session - SSE realtime push (Agent subscribes here)
990
1087
  GET /health - Health check
991
- GET /api/status - All sessions ${AUTH_TOKEN ? "(auth required)" : ""}
992
- POST /api/task - Send task via REST ${AUTH_TOKEN ? "(auth required)" : ""}
993
- GET /api/tasks - Tasks table (V2) ${AUTH_TOKEN ? "(auth required)" : ""}
994
- GET /api/completions - Recent completions ${AUTH_TOKEN ? "(auth required)" : ""}
995
- GET /api/tmux/:name - Capture tmux pane output ${AUTH_TOKEN ? "(auth required)" : ""}
996
- POST /api/tmux/:name/send - Send keys to tmux ${AUTH_TOKEN ? "(auth required)" : ""}
997
-
998
- Auth: ${AUTH_TOKEN ? "Bearer token enabled (set COMMHUB_AUTH_TOKEN)" : "disabled (set COMMHUB_AUTH_TOKEN to enable)"}
1088
+ GET /api/status - All sessions (auth required)
1089
+ POST /api/task - Send task via REST (auth required)
1090
+ GET /api/tasks - Tasks table (V2) (auth required)
1091
+ GET /api/completions - Recent completions (auth required)
1092
+ GET /api/tmux/:name - Capture tmux pane output (${TMUX_ENABLED ? "admin + localhost/allowlist required" : "disabled"})
1093
+ POST /api/tmux/:name/send - Send keys to tmux (${TMUX_ENABLED ? "admin + localhost/allowlist required" : "disabled"})
1094
+
1095
+ Security: ${SECURITY_LABEL}
999
1096
  `,
1000
1097
  { status: 200, headers: { "Content-Type": "text/plain" } }
1001
1098
  ));
@@ -1004,7 +1101,7 @@ Auth: ${AUTH_TOKEN ? "Bearer token enabled (set COMMHUB_AUTH_TOKEN)" : "disabled
1004
1101
  // ── WebSocket handler for tmux terminal streaming ──
1005
1102
  websocket: {
1006
1103
  open(ws) {
1007
- const { tmuxName } = ws.data as { tmuxName: string };
1104
+ const { tmuxName } = ws.data as unknown as { tmuxName: string };
1008
1105
  console.log(`[ws] tmux terminal opened: ${tmuxName}`);
1009
1106
  let lastOutput = "";
1010
1107
 
@@ -1049,7 +1146,7 @@ Auth: ${AUTH_TOKEN ? "Bearer token enabled (set COMMHUB_AUTH_TOKEN)" : "disabled
1049
1146
  },
1050
1147
 
1051
1148
  async message(ws, message) {
1052
- const { tmuxName } = ws.data as { tmuxName: string };
1149
+ const { tmuxName } = ws.data as unknown as { tmuxName: string };
1053
1150
  try {
1054
1151
  const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message));
1055
1152
 
@@ -1075,7 +1172,7 @@ Auth: ${AUTH_TOKEN ? "Bearer token enabled (set COMMHUB_AUTH_TOKEN)" : "disabled
1075
1172
  },
1076
1173
 
1077
1174
  close(ws) {
1078
- const { tmuxName } = ws.data as { tmuxName: string };
1175
+ const { tmuxName } = ws.data as unknown as { tmuxName: string };
1079
1176
  console.log(`[ws] tmux terminal closed: ${tmuxName}`);
1080
1177
  const interval = wsTmuxIntervals.get(ws);
1081
1178
  if (interval) { clearInterval(interval); wsTmuxIntervals.delete(ws); }
@@ -1096,7 +1193,8 @@ console.log(`
1096
1193
  ╔══════════════════════════════════════════════════╗
1097
1194
  ║ CommHub MCP Server v${SERVER_VERSION} ║
1098
1195
  ║ Transport: Streamable HTTP (Bun native) ║
1099
- Auth: ${AUTH_TOKEN ? "ENABLED (Bearer token)" : "DISABLED (set COMMHUB_AUTH_TOKEN)"}${"".padEnd(AUTH_TOKEN ? 5 : 0)}║
1196
+ Security: ${SECURITY_LABEL}${" ".repeat(Math.max(0, 33 - SECURITY_LABEL.length))}║
1197
+ ║ Tmux: ${TMUX_ENABLED ? "ENABLED (admin + localhost/allowlist)" : "DISABLED (set COMMHUB_ENABLE_TMUX=1)"}${" ".repeat(Math.max(0, TMUX_ENABLED ? 0 : 2))}║
1100
1198
  ║ ║
1101
1199
  ║ MCP: http://${HOST}:${PORT}/mcp ║
1102
1200
  ║ REST: http://${HOST}:${PORT}/api ║
@@ -0,0 +1,100 @@
1
+ const WEAK_PASSWORDS_RAW = `
2
+ 123456
3
+ password
4
+ 123456789
5
+ 12345
6
+ 12345678
7
+ qwerty
8
+ 1234567
9
+ 111111
10
+ 1234567890
11
+ 123123
12
+ abc123
13
+ 1234
14
+ password1
15
+ iloveyou
16
+ 1q2w3e4r
17
+ 000000
18
+ qwerty123
19
+ zaq12wsx
20
+ dragon
21
+ sunshine
22
+ princess
23
+ letmein
24
+ monkey
25
+ football
26
+ baseball
27
+ welcome
28
+ admin
29
+ login
30
+ master
31
+ hello
32
+ freedom
33
+ whatever
34
+ trustno1
35
+ qazwsx
36
+ 654321
37
+ superman
38
+ batman
39
+ passw0rd
40
+ password123
41
+ asdfgh
42
+ zxcvbnm
43
+ qwertyuiop
44
+ 1qaz2wsx
45
+ lovely
46
+ flower
47
+ hunter
48
+ shadow
49
+ buster
50
+ soccer
51
+ hockey
52
+ killer
53
+ george
54
+ charlie
55
+ andrew
56
+ michael
57
+ jessica
58
+ michelle
59
+ pepper
60
+ jordan
61
+ harley
62
+ ranger
63
+ ginger
64
+ joshua
65
+ maggie
66
+ mustang
67
+ computer
68
+ internet
69
+ secret
70
+ summer
71
+ winter
72
+ spring
73
+ autumn
74
+ orange
75
+ banana
76
+ cookie
77
+ coffee
78
+ matrix
79
+ starwars
80
+ pokemon
81
+ naruto
82
+ 888888
83
+ 666666
84
+ 5201314
85
+ 121212
86
+ 112233
87
+ 159753
88
+ 987654321
89
+ `;
90
+
91
+ const weak = new Set(WEAK_PASSWORDS_RAW.trim().split(/\s+/).map((p) => p.toLowerCase()));
92
+
93
+ for (let i = 0; i <= 999; i++) {
94
+ weak.add(String(i).padStart(6, "0"));
95
+ weak.add(`password${i}`);
96
+ weak.add(`qwerty${i}`);
97
+ }
98
+
99
+ export const WEAK_PASSWORDS = weak;
100
+
package/src/push.ts CHANGED
@@ -15,23 +15,24 @@ function ts(): string {
15
15
  }
16
16
 
17
17
  /** 创建 SSE Response 并注册到 clients map */
18
- export function createSSEStream(sessionName: string): Response {
18
+ export function createSSEStream(sessionName: string, networkId?: string | null): Response {
19
19
  const encoder = new TextEncoder();
20
20
  let ctrl: ReadableStreamDefaultController;
21
+ const key = clientKey(sessionName, networkId);
21
22
 
22
23
  const stream = new ReadableStream({
23
24
  start(controller) {
24
25
  ctrl = controller;
25
26
  const client: SSEClient = { controller, encoder };
26
27
 
27
- if (!clients.has(sessionName)) {
28
- clients.set(sessionName, []);
28
+ if (!clients.has(key)) {
29
+ clients.set(key, []);
29
30
  }
30
- clients.get(sessionName)!.push(client);
31
- console.log(`[${ts()}] SSE ← ${sessionName} connected (${clients.get(sessionName)!.length} clients)`);
31
+ clients.get(key)!.push(client);
32
+ console.log(`[${ts()}] SSE ← ${key} connected (${clients.get(key)!.length} clients)`);
32
33
 
33
34
  // 发送初始心跳
34
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "connected", session: sessionName })}\n\n`));
35
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "connected", session: sessionName, network_id: networkId ?? null })}\n\n`));
35
36
 
36
37
  // Periodic keepalive every 30s to prevent proxy/LB idle timeout
37
38
  const keepalive = setInterval(() => {
@@ -45,15 +46,15 @@ export function createSSEStream(sessionName: string): Response {
45
46
  },
46
47
  cancel() {
47
48
  // 断线清理
48
- const arr = clients.get(sessionName);
49
+ const arr = clients.get(key);
49
50
  if (arr) {
50
51
  const idx = arr.findIndex(c => c.controller === ctrl);
51
52
  if (idx !== -1) {
52
53
  clearInterval((arr[idx] as any)._keepalive);
53
54
  arr.splice(idx, 1);
54
55
  }
55
- if (arr.length === 0) clients.delete(sessionName);
56
- console.log(`[${ts()}] SSE ✕ ${sessionName} disconnected (${arr.length} remaining)`);
56
+ if (arr.length === 0) clients.delete(key);
57
+ console.log(`[${ts()}] SSE ✕ ${key} disconnected (${arr.length} remaining)`);
57
58
  }
58
59
  },
59
60
  });
@@ -67,9 +68,13 @@ export function createSSEStream(sessionName: string): Response {
67
68
  });
68
69
  }
69
70
 
71
+ function clientKey(sessionName: string, networkId?: string | null): string {
72
+ return `${networkId || "global"}:${sessionName}`;
73
+ }
74
+
70
75
  /** 推送事件给指定 session 的所有 SSE 连接 */
71
- export function pushEvent(sessionName: string, event: Record<string, unknown>): void {
72
- const arr = clients.get(sessionName);
76
+ export function pushEvent(sessionName: string, event: Record<string, unknown>, networkId?: string | null): void {
77
+ const arr = clients.get(clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined)));
73
78
  if (!arr || arr.length === 0) return;
74
79
 
75
80
  const data = `data: ${JSON.stringify(event)}\n\n`;
@@ -87,14 +92,7 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>):
87
92
  for (let i = dead.length - 1; i >= 0; i--) {
88
93
  arr.splice(dead[i], 1);
89
94
  }
90
- if (arr.length === 0) clients.delete(sessionName);
91
- }
92
-
93
- /** 广播给多个 session */
94
- export function pushBroadcast(sessionNames: string[], event: Record<string, unknown>): void {
95
- for (const name of sessionNames) {
96
- pushEvent(name, event);
97
- }
95
+ if (arr.length === 0) clients.delete(clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined)));
98
96
  }
99
97
 
100
98
  /** 获取当前 SSE 连接统计 */