opc-agent 4.1.0 → 4.1.2
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/.github/ISSUE_TEMPLATE/bug_report.md +20 -20
- package/.github/ISSUE_TEMPLATE/feature_request.md +14 -14
- package/.github/PULL_REQUEST_TEMPLATE.md +13 -13
- package/CHANGELOG.md +48 -48
- package/CONTRIBUTING.md +36 -36
- package/README.zh-CN.md +497 -497
- package/USABILITY-ISSUES.md +73 -0
- package/dist/channels/web.js +8 -2
- package/dist/channels/wechat.js +6 -6
- package/dist/cli.js +200 -85
- package/dist/core/runtime.js +37 -15
- package/dist/deploy/index.js +56 -56
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +105 -10
- package/dist/memory/deepbrain.d.ts +1 -1
- package/dist/memory/deepbrain.js +95 -4
- package/dist/scheduler/cron-engine.js +3 -36
- package/dist/studio/server.js +30 -1
- package/dist/studio-ui/index.html +230 -10
- package/dist/ui/components.js +105 -105
- package/examples/README.md +22 -22
- package/examples/basic-agent.ts +90 -90
- package/examples/brain-integration.ts +71 -71
- package/examples/multi-channel.ts +74 -74
- package/fix-sidebar.mjs +188 -188
- package/install.ps1 +154 -154
- package/install.sh +164 -164
- package/package.json +1 -1
- package/scripts/install.ps1 +31 -31
- package/scripts/install.sh +40 -40
- package/serve-studio.js +13 -13
- package/serve-test.js +25 -25
- package/src/channels/dingtalk.ts +46 -46
- package/src/channels/email.ts +351 -351
- package/src/channels/feishu.ts +349 -349
- package/src/channels/googlechat.ts +42 -42
- package/src/channels/imessage.ts +31 -31
- package/src/channels/irc.ts +82 -82
- package/src/channels/line.ts +32 -32
- package/src/channels/matrix.ts +33 -33
- package/src/channels/mattermost.ts +57 -57
- package/src/channels/msteams.ts +32 -32
- package/src/channels/nostr.ts +32 -32
- package/src/channels/qq.ts +33 -33
- package/src/channels/signal.ts +32 -32
- package/src/channels/sms.ts +33 -33
- package/src/channels/telegram.ts +616 -616
- package/src/channels/twitch.ts +65 -65
- package/src/channels/voice-call.ts +100 -100
- package/src/channels/web.ts +8 -2
- package/src/channels/websocket.ts +399 -399
- package/src/channels/wechat.ts +329 -329
- package/src/channels/whatsapp.ts +32 -32
- package/src/cli/chat.ts +99 -99
- package/src/cli/setup.ts +314 -314
- package/src/cli.ts +195 -92
- package/src/core/agent.ts +476 -476
- package/src/core/api-server.ts +277 -277
- package/src/core/audio.ts +98 -98
- package/src/core/collaboration.ts +275 -275
- package/src/core/context-discovery.ts +85 -85
- package/src/core/context-refs.ts +140 -140
- package/src/core/gateway.ts +106 -106
- package/src/core/heartbeat.ts +51 -51
- package/src/core/hooks.ts +105 -105
- package/src/core/ide-bridge.ts +133 -133
- package/src/core/node-network.ts +86 -86
- package/src/core/profiles.ts +122 -122
- package/src/core/runtime.ts +25 -0
- package/src/core/scheduler.ts +187 -187
- package/src/core/session-manager.ts +137 -137
- package/src/core/subagent.ts +98 -98
- package/src/core/vision.ts +180 -180
- package/src/core/workflow-graph.ts +365 -365
- package/src/daemon.ts +96 -96
- package/src/deploy/index.ts +255 -255
- package/src/doctor.ts +98 -11
- package/src/eval/index.ts +211 -211
- package/src/eval/suites/basic.json +16 -16
- package/src/eval/suites/memory.json +12 -12
- package/src/eval/suites/safety.json +14 -14
- package/src/hub/brain-seed.ts +54 -54
- package/src/hub/client.ts +60 -60
- package/src/mcp/servers/calculator-mcp.ts +65 -65
- package/src/mcp/servers/crypto-mcp.ts +73 -73
- package/src/mcp/servers/database-mcp.ts +72 -72
- package/src/mcp/servers/datetime-mcp.ts +69 -69
- package/src/mcp/servers/filesystem.ts +66 -66
- package/src/mcp/servers/github-mcp.ts +58 -58
- package/src/mcp/servers/index.ts +63 -63
- package/src/mcp/servers/json-mcp.ts +102 -102
- package/src/mcp/servers/memory-mcp.ts +56 -56
- package/src/mcp/servers/regex-mcp.ts +53 -53
- package/src/mcp/servers/web-mcp.ts +49 -49
- package/src/memory/context-compressor.ts +189 -189
- package/src/memory/deepbrain.ts +99 -5
- package/src/memory/seed-loader.ts +212 -212
- package/src/memory/user-profiler.ts +215 -215
- package/src/plugins/content-filter.ts +23 -23
- package/src/plugins/logger.ts +18 -18
- package/src/plugins/rate-limiter.ts +38 -38
- package/src/protocols/a2a/client.ts +132 -132
- package/src/protocols/a2a/index.ts +8 -8
- package/src/protocols/a2a/server.ts +333 -333
- package/src/protocols/a2a/types.ts +88 -88
- package/src/protocols/a2a/utils.ts +50 -50
- package/src/protocols/agui/client.ts +83 -83
- package/src/protocols/agui/index.ts +4 -4
- package/src/protocols/agui/server.ts +218 -218
- package/src/protocols/agui/types.ts +153 -153
- package/src/protocols/index.ts +2 -2
- package/src/protocols/mcp/agent-tools.ts +134 -134
- package/src/protocols/mcp/index.ts +8 -8
- package/src/protocols/mcp/server.ts +262 -262
- package/src/protocols/mcp/types.ts +69 -69
- package/src/providers/index.ts +632 -632
- package/src/publish/index.ts +376 -376
- package/src/scheduler/cron-engine.ts +191 -191
- package/src/scheduler/index.ts +2 -2
- package/src/schema/oad.ts +217 -217
- package/src/security/approval.ts +131 -131
- package/src/security/approvals.ts +143 -143
- package/src/security/elevated.ts +105 -105
- package/src/security/guardrails.ts +248 -248
- package/src/security/index.ts +9 -9
- package/src/security/keys.ts +87 -87
- package/src/security/secrets.ts +129 -129
- package/src/skills/builtin/index.ts +408 -408
- package/src/skills/marketplace.ts +113 -113
- package/src/skills/types.ts +42 -42
- package/src/studio/server.ts +31 -1
- package/src/studio/templates-data.ts +178 -178
- package/src/studio-ui/index.html +230 -10
- package/src/telemetry/index.ts +324 -324
- package/src/tools/builtin/browser.ts +299 -299
- package/src/tools/builtin/datetime.ts +41 -41
- package/src/tools/builtin/file.ts +107 -107
- package/src/tools/builtin/home-assistant.ts +116 -116
- package/src/tools/builtin/rl-tools.ts +243 -243
- package/src/tools/builtin/shell.ts +43 -43
- package/src/tools/builtin/vision.ts +64 -64
- package/src/tools/builtin/web-search.ts +126 -126
- package/src/tools/builtin/web.ts +35 -35
- package/src/tools/document-processor.ts +213 -213
- package/src/tools/image-generator.ts +150 -150
- package/src/tools/integrations/calendar.ts +73 -73
- package/src/tools/integrations/code-exec.ts +39 -39
- package/src/tools/integrations/csv-analyzer.ts +92 -92
- package/src/tools/integrations/database.ts +44 -44
- package/src/tools/integrations/email-send.ts +76 -76
- package/src/tools/integrations/git-tool.ts +42 -42
- package/src/tools/integrations/github-tool.ts +76 -76
- package/src/tools/integrations/image-gen.ts +56 -56
- package/src/tools/integrations/index.ts +92 -92
- package/src/tools/integrations/jira.ts +83 -83
- package/src/tools/integrations/notion.ts +71 -71
- package/src/tools/integrations/npm-tool.ts +48 -48
- package/src/tools/integrations/pdf-reader.ts +58 -58
- package/src/tools/integrations/slack.ts +65 -65
- package/src/tools/integrations/summarizer.ts +49 -49
- package/src/tools/integrations/translator.ts +48 -48
- package/src/tools/integrations/trello.ts +60 -60
- package/src/tools/integrations/vector-search.ts +42 -42
- package/src/tools/integrations/web-scraper.ts +47 -47
- package/src/tools/integrations/web-search.ts +58 -58
- package/src/tools/integrations/webhook.ts +38 -38
- package/src/tools/mcp-client.ts +131 -131
- package/src/tools/web-scraper.ts +179 -179
- package/src/tools/web-search.ts +180 -180
- package/src/ui/components.ts +127 -127
- package/srv-out.txt +1 -1
- package/templates/ecommerce-assistant/README.md +45 -45
- package/templates/ecommerce-assistant/oad.yaml +47 -47
- package/templates/tech-support/README.md +43 -43
- package/templates/tech-support/oad.yaml +45 -45
- package/test-agent/Dockerfile +9 -9
- package/test-agent/README.md +50 -50
- package/test-agent/agent.yaml +23 -23
- package/test-agent/docker-compose.yml +11 -11
- package/test-agent/oad.yaml +31 -31
- package/test-agent/package-lock.json +1492 -1492
- package/test-agent/package.json +17 -17
- package/test-agent/src/index.ts +24 -24
- package/test-agent/src/skills/echo.ts +15 -15
- package/test-agent/tsconfig.json +24 -24
- package/test-full.js +43 -43
- package/test-sidebar.js +22 -22
- package/test-studio3.js +75 -75
- package/test-studio4.js +41 -41
- package/tests/a2a-protocol.test.ts +285 -285
- package/tests/agui-protocol.test.ts +246 -246
- package/tests/api-server.test.ts +148 -148
- package/tests/approvals.test.ts +89 -89
- package/tests/audio.test.ts +40 -40
- package/tests/brain-seed-extended.test.ts +490 -490
- package/tests/brain-seed.test.ts +239 -239
- package/tests/browser.test.ts +179 -179
- package/tests/channels/discord.test.ts +79 -79
- package/tests/channels/email.test.ts +148 -148
- package/tests/channels/feishu.test.ts +123 -123
- package/tests/channels/telegram.test.ts +129 -129
- package/tests/channels/websocket.test.ts +53 -53
- package/tests/channels/wechat.test.ts +170 -170
- package/tests/channels-extra.test.ts +45 -45
- package/tests/chat-cli.test.ts +160 -160
- package/tests/cli.test.ts +46 -46
- package/tests/context-compressor.test.ts +172 -172
- package/tests/context-refs.test.ts +121 -121
- package/tests/cron-engine.test.ts +101 -101
- package/tests/daemon.test.ts +135 -135
- package/tests/deepbrain-wire.test.ts +234 -234
- package/tests/deploy-and-dag.test.ts +196 -196
- package/tests/doctor.test.ts +38 -38
- package/tests/document-processor.test.ts +69 -69
- package/tests/e2e-nocode.test.ts +442 -442
- package/tests/elevated.test.ts +69 -69
- package/tests/eval.test.ts +173 -173
- package/tests/gateway.test.ts +63 -63
- package/tests/guardrails.test.ts +177 -177
- package/tests/home-assistant.test.ts +40 -40
- package/tests/hooks.test.ts +79 -79
- package/tests/ide-bridge.test.ts +38 -38
- package/tests/image-generator.test.ts +84 -84
- package/tests/init-role.test.ts +124 -124
- package/tests/integrations.test.ts +249 -249
- package/tests/mcp-client.test.ts +92 -92
- package/tests/mcp-server.test.ts +178 -178
- package/tests/mcp-servers.test.ts +260 -260
- package/tests/node-network.test.ts +74 -74
- package/tests/plugin-a2a-enhanced.test.ts +230 -230
- package/tests/profiles.test.ts +61 -61
- package/tests/publish.test.ts +231 -231
- package/tests/rl-tools.test.ts +93 -93
- package/tests/sandbox-manager.test.ts +46 -46
- package/tests/scheduler.test.ts +200 -200
- package/tests/secrets.test.ts +107 -107
- package/tests/security-enhanced.test.ts +233 -233
- package/tests/settings-api.test.ts +148 -148
- package/tests/setup.test.ts +73 -73
- package/tests/subagent.test.ts +193 -193
- package/tests/telegram-discord.test.ts +60 -60
- package/tests/telemetry.test.ts +186 -186
- package/tests/user-profiler.test.ts +169 -169
- package/tests/v090-features.test.ts +254 -254
- package/tests/vision.test.ts +61 -61
- package/tests/voice-call.test.ts +47 -47
- package/tests/voice-enhanced.test.ts +169 -169
- package/tests/voice-interaction.test.ts +38 -38
- package/tests/web-search.test.ts +155 -155
- package/tests/workflow-graph.test.ts +279 -279
- package/tutorial/customer-service-agent/README.md +612 -612
- package/tutorial/customer-service-agent/SOUL.md +26 -26
- package/tutorial/customer-service-agent/agent.yaml +63 -63
- package/tutorial/customer-service-agent/package.json +19 -19
- package/tutorial/customer-service-agent/src/index.ts +69 -69
- package/tutorial/customer-service-agent/src/skills/faq.ts +27 -27
- package/tutorial/customer-service-agent/src/skills/ticket.ts +22 -22
- package/tutorial/customer-service-agent/tsconfig.json +14 -14
|
@@ -1,399 +1,399 @@
|
|
|
1
|
-
import type { Message } from '../core/types';
|
|
2
|
-
import { BaseChannel } from './index';
|
|
3
|
-
import { WebSocketServer, type WebSocket } from 'ws';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* WebSocket Channel — v1.1.0
|
|
7
|
-
*
|
|
8
|
-
* Enhanced with:
|
|
9
|
-
* - Room support (multiple clients in a room)
|
|
10
|
-
* - Heartbeat/ping-pong to detect disconnected clients
|
|
11
|
-
* - Reconnection handling (session persistence)
|
|
12
|
-
* - Binary message support
|
|
13
|
-
* - Connection authentication (optional token in query string)
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
export interface WebSocketChannelConfig {
|
|
17
|
-
port?: number;
|
|
18
|
-
/** Heartbeat interval in ms (default: 30000) */
|
|
19
|
-
heartbeatInterval?: number;
|
|
20
|
-
/** Valid auth tokens (if empty, no auth required) */
|
|
21
|
-
authTokens?: string[];
|
|
22
|
-
/** Max clients per room (default: 100) */
|
|
23
|
-
maxClientsPerRoom?: number;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface ClientInfo {
|
|
27
|
-
ws: WebSocket;
|
|
28
|
-
sessionId: string;
|
|
29
|
-
rooms: Set<string>;
|
|
30
|
-
isAlive: boolean;
|
|
31
|
-
authenticated: boolean;
|
|
32
|
-
connectedAt: number;
|
|
33
|
-
lastMessageAt: number;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export class WebSocketChannel extends BaseChannel {
|
|
37
|
-
readonly type = 'websocket';
|
|
38
|
-
private wss: WebSocketServer | null = null;
|
|
39
|
-
private config: Required<WebSocketChannelConfig>;
|
|
40
|
-
private clients: Map<string, ClientInfo> = new Map(); // sessionId -> ClientInfo
|
|
41
|
-
private rooms: Map<string, Set<string>> = new Map(); // roomId -> Set<sessionId>
|
|
42
|
-
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
43
|
-
|
|
44
|
-
constructor(configOrPort: number | WebSocketChannelConfig = 3002) {
|
|
45
|
-
super();
|
|
46
|
-
if (typeof configOrPort === 'number') {
|
|
47
|
-
this.config = { port: configOrPort, heartbeatInterval: 30000, authTokens: [], maxClientsPerRoom: 100 };
|
|
48
|
-
} else {
|
|
49
|
-
this.config = {
|
|
50
|
-
port: configOrPort.port ?? 3002,
|
|
51
|
-
heartbeatInterval: configOrPort.heartbeatInterval ?? 30000,
|
|
52
|
-
authTokens: configOrPort.authTokens ?? [],
|
|
53
|
-
maxClientsPerRoom: configOrPort.maxClientsPerRoom ?? 100,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async start(): Promise<void> {
|
|
59
|
-
return new Promise((resolve) => {
|
|
60
|
-
this.wss = new WebSocketServer({ port: this.config.port });
|
|
61
|
-
|
|
62
|
-
this.wss.on('connection', (ws, req) => {
|
|
63
|
-
const url = new URL(req.url ?? '/', `http://localhost:${this.config.port}`);
|
|
64
|
-
const token = url.searchParams.get('token');
|
|
65
|
-
const sessionId = url.searchParams.get('sessionId') ?? `ws_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
66
|
-
|
|
67
|
-
// Authentication check
|
|
68
|
-
if (this.config.authTokens.length > 0) {
|
|
69
|
-
if (!token || !this.config.authTokens.includes(token)) {
|
|
70
|
-
ws.close(4001, 'Unauthorized');
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Handle reconnection: if sessionId already exists, replace the connection
|
|
76
|
-
const existing = this.clients.get(sessionId);
|
|
77
|
-
if (existing) {
|
|
78
|
-
try { existing.ws.close(4000, 'Replaced by new connection'); } catch {}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const clientInfo: ClientInfo = {
|
|
82
|
-
ws,
|
|
83
|
-
sessionId,
|
|
84
|
-
rooms: existing?.rooms ?? new Set(),
|
|
85
|
-
isAlive: true,
|
|
86
|
-
authenticated: true,
|
|
87
|
-
connectedAt: Date.now(),
|
|
88
|
-
lastMessageAt: Date.now(),
|
|
89
|
-
};
|
|
90
|
-
this.clients.set(sessionId, clientInfo);
|
|
91
|
-
|
|
92
|
-
// Re-register in rooms after reconnect
|
|
93
|
-
for (const roomId of clientInfo.rooms) {
|
|
94
|
-
const room = this.rooms.get(roomId);
|
|
95
|
-
if (room) room.add(sessionId);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
ws.on('pong', () => {
|
|
99
|
-
clientInfo.isAlive = true;
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
ws.on('message', async (data, isBinary) => {
|
|
103
|
-
clientInfo.lastMessageAt = Date.now();
|
|
104
|
-
clientInfo.isAlive = true;
|
|
105
|
-
|
|
106
|
-
if (isBinary) {
|
|
107
|
-
await this.handleBinaryMessage(clientInfo, data as Buffer);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
try {
|
|
112
|
-
const parsed = JSON.parse(data.toString());
|
|
113
|
-
await this.handleTextMessage(clientInfo, parsed);
|
|
114
|
-
} catch {
|
|
115
|
-
ws.send(JSON.stringify({ error: 'Invalid message format' }));
|
|
116
|
-
}
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
ws.on('close', () => {
|
|
120
|
-
// Don't immediately remove - allow reconnection window
|
|
121
|
-
const info = this.clients.get(sessionId);
|
|
122
|
-
if (info && info.ws === ws) {
|
|
123
|
-
// Mark as disconnected but keep for potential reconnection
|
|
124
|
-
setTimeout(() => {
|
|
125
|
-
const current = this.clients.get(sessionId);
|
|
126
|
-
if (current && current.ws === ws) {
|
|
127
|
-
this.removeClient(sessionId);
|
|
128
|
-
}
|
|
129
|
-
}, 60000); // 60s reconnection window
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
ws.send(JSON.stringify({
|
|
134
|
-
type: 'connected',
|
|
135
|
-
sessionId,
|
|
136
|
-
timestamp: Date.now(),
|
|
137
|
-
}));
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// Start heartbeat
|
|
141
|
-
this.heartbeatTimer = setInterval(() => {
|
|
142
|
-
for (const [sessionId, info] of this.clients) {
|
|
143
|
-
if (!info.isAlive) {
|
|
144
|
-
try { info.ws.terminate(); } catch {}
|
|
145
|
-
this.removeClient(sessionId);
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
info.isAlive = false;
|
|
149
|
-
try { info.ws.ping(); } catch {}
|
|
150
|
-
}
|
|
151
|
-
}, this.config.heartbeatInterval);
|
|
152
|
-
|
|
153
|
-
this.wss.on('listening', () => {
|
|
154
|
-
console.log(`[WebSocketChannel] Listening on port ${this.config.port}`);
|
|
155
|
-
resolve();
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async stop(): Promise<void> {
|
|
161
|
-
if (this.heartbeatTimer) {
|
|
162
|
-
clearInterval(this.heartbeatTimer);
|
|
163
|
-
this.heartbeatTimer = null;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
for (const [, info] of this.clients) {
|
|
167
|
-
try { info.ws.close(); } catch {}
|
|
168
|
-
}
|
|
169
|
-
this.clients.clear();
|
|
170
|
-
this.rooms.clear();
|
|
171
|
-
|
|
172
|
-
return new Promise((resolve, reject) => {
|
|
173
|
-
if (!this.wss) return resolve();
|
|
174
|
-
this.wss.close((err) => (err ? reject(err) : resolve()));
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Handle text (JSON) messages */
|
|
179
|
-
private async handleTextMessage(client: ClientInfo, parsed: any): Promise<void> {
|
|
180
|
-
const type = parsed.type ?? 'message';
|
|
181
|
-
|
|
182
|
-
switch (type) {
|
|
183
|
-
case 'join': {
|
|
184
|
-
const roomId = parsed.room;
|
|
185
|
-
if (!roomId) {
|
|
186
|
-
client.ws.send(JSON.stringify({ error: 'room is required for join' }));
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
this.joinRoom(client.sessionId, roomId);
|
|
190
|
-
client.ws.send(JSON.stringify({ type: 'joined', room: roomId, members: this.getRoomMembers(roomId).length }));
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
case 'leave': {
|
|
195
|
-
const roomId = parsed.room;
|
|
196
|
-
if (roomId) {
|
|
197
|
-
this.leaveRoom(client.sessionId, roomId);
|
|
198
|
-
client.ws.send(JSON.stringify({ type: 'left', room: roomId }));
|
|
199
|
-
}
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
case 'room_message': {
|
|
204
|
-
const roomId = parsed.room;
|
|
205
|
-
const content = parsed.content ?? parsed.message;
|
|
206
|
-
if (roomId && content) {
|
|
207
|
-
this.broadcastToRoom(roomId, {
|
|
208
|
-
type: 'room_message',
|
|
209
|
-
room: roomId,
|
|
210
|
-
from: client.sessionId,
|
|
211
|
-
content,
|
|
212
|
-
timestamp: Date.now(),
|
|
213
|
-
}, client.sessionId);
|
|
214
|
-
}
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
case 'ping': {
|
|
219
|
-
client.ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
default: {
|
|
224
|
-
// Regular chat message
|
|
225
|
-
if (!this.handler) return;
|
|
226
|
-
|
|
227
|
-
const msg: Message = {
|
|
228
|
-
id: parsed.id ?? `ws_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
229
|
-
role: 'user',
|
|
230
|
-
content: parsed.content ?? parsed.message ?? JSON.stringify(parsed),
|
|
231
|
-
timestamp: Date.now(),
|
|
232
|
-
metadata: {
|
|
233
|
-
sessionId: client.sessionId,
|
|
234
|
-
platform: 'websocket',
|
|
235
|
-
room: parsed.room,
|
|
236
|
-
},
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
const response = await this.handler(msg);
|
|
240
|
-
client.ws.send(JSON.stringify({
|
|
241
|
-
id: response.id,
|
|
242
|
-
content: response.content,
|
|
243
|
-
timestamp: response.timestamp,
|
|
244
|
-
}));
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Handle binary messages */
|
|
250
|
-
private async handleBinaryMessage(client: ClientInfo, data: Buffer): Promise<void> {
|
|
251
|
-
// Emit binary data with metadata
|
|
252
|
-
client.ws.send(JSON.stringify({
|
|
253
|
-
type: 'binary_ack',
|
|
254
|
-
size: data.length,
|
|
255
|
-
timestamp: Date.now(),
|
|
256
|
-
}));
|
|
257
|
-
|
|
258
|
-
// If handler exists, pass as base64
|
|
259
|
-
if (this.handler) {
|
|
260
|
-
const msg: Message = {
|
|
261
|
-
id: `ws_bin_${Date.now()}`,
|
|
262
|
-
role: 'user',
|
|
263
|
-
content: `[binary:${data.length} bytes]`,
|
|
264
|
-
timestamp: Date.now(),
|
|
265
|
-
metadata: {
|
|
266
|
-
sessionId: client.sessionId,
|
|
267
|
-
platform: 'websocket',
|
|
268
|
-
binary: true,
|
|
269
|
-
binaryData: data.toString('base64'),
|
|
270
|
-
},
|
|
271
|
-
};
|
|
272
|
-
const response = await this.handler(msg);
|
|
273
|
-
client.ws.send(JSON.stringify({
|
|
274
|
-
id: response.id,
|
|
275
|
-
content: response.content,
|
|
276
|
-
timestamp: response.timestamp,
|
|
277
|
-
}));
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** Join a room */
|
|
282
|
-
joinRoom(sessionId: string, roomId: string): boolean {
|
|
283
|
-
const client = this.clients.get(sessionId);
|
|
284
|
-
if (!client) return false;
|
|
285
|
-
|
|
286
|
-
if (!this.rooms.has(roomId)) {
|
|
287
|
-
this.rooms.set(roomId, new Set());
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const room = this.rooms.get(roomId)!;
|
|
291
|
-
if (room.size >= this.config.maxClientsPerRoom) {
|
|
292
|
-
client.ws.send(JSON.stringify({ error: 'Room is full', room: roomId }));
|
|
293
|
-
return false;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
room.add(sessionId);
|
|
297
|
-
client.rooms.add(roomId);
|
|
298
|
-
|
|
299
|
-
// Notify other room members
|
|
300
|
-
this.broadcastToRoom(roomId, {
|
|
301
|
-
type: 'member_joined',
|
|
302
|
-
room: roomId,
|
|
303
|
-
sessionId,
|
|
304
|
-
members: room.size,
|
|
305
|
-
timestamp: Date.now(),
|
|
306
|
-
}, sessionId);
|
|
307
|
-
|
|
308
|
-
return true;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/** Leave a room */
|
|
312
|
-
leaveRoom(sessionId: string, roomId: string): void {
|
|
313
|
-
const client = this.clients.get(sessionId);
|
|
314
|
-
if (client) client.rooms.delete(roomId);
|
|
315
|
-
|
|
316
|
-
const room = this.rooms.get(roomId);
|
|
317
|
-
if (room) {
|
|
318
|
-
room.delete(sessionId);
|
|
319
|
-
if (room.size === 0) {
|
|
320
|
-
this.rooms.delete(roomId);
|
|
321
|
-
} else {
|
|
322
|
-
this.broadcastToRoom(roomId, {
|
|
323
|
-
type: 'member_left',
|
|
324
|
-
room: roomId,
|
|
325
|
-
sessionId,
|
|
326
|
-
members: room.size,
|
|
327
|
-
timestamp: Date.now(),
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/** Remove client completely */
|
|
334
|
-
private removeClient(sessionId: string): void {
|
|
335
|
-
const client = this.clients.get(sessionId);
|
|
336
|
-
if (!client) return;
|
|
337
|
-
|
|
338
|
-
for (const roomId of client.rooms) {
|
|
339
|
-
this.leaveRoom(sessionId, roomId);
|
|
340
|
-
}
|
|
341
|
-
this.clients.delete(sessionId);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/** Get room member session IDs */
|
|
345
|
-
getRoomMembers(roomId: string): string[] {
|
|
346
|
-
return [...(this.rooms.get(roomId) ?? [])];
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/** Get all rooms */
|
|
350
|
-
getRooms(): string[] {
|
|
351
|
-
return [...this.rooms.keys()];
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/** Broadcast to all clients */
|
|
355
|
-
broadcast(content: string): void {
|
|
356
|
-
const msg = JSON.stringify({ type: 'broadcast', content, timestamp: Date.now() });
|
|
357
|
-
for (const [, info] of this.clients) {
|
|
358
|
-
if (info.ws.readyState === 1) {
|
|
359
|
-
info.ws.send(msg);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/** Broadcast to all clients in a room */
|
|
365
|
-
broadcastToRoom(roomId: string, data: any, excludeSessionId?: string): void {
|
|
366
|
-
const room = this.rooms.get(roomId);
|
|
367
|
-
if (!room) return;
|
|
368
|
-
|
|
369
|
-
const msg = typeof data === 'string' ? data : JSON.stringify(data);
|
|
370
|
-
for (const sessionId of room) {
|
|
371
|
-
if (sessionId === excludeSessionId) continue;
|
|
372
|
-
const client = this.clients.get(sessionId);
|
|
373
|
-
if (client && client.ws.readyState === 1) {
|
|
374
|
-
client.ws.send(msg);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/** Send to specific session */
|
|
380
|
-
sendToSession(sessionId: string, data: any): boolean {
|
|
381
|
-
const client = this.clients.get(sessionId);
|
|
382
|
-
if (!client || client.ws.readyState !== 1) return false;
|
|
383
|
-
client.ws.send(typeof data === 'string' ? data : JSON.stringify(data));
|
|
384
|
-
return true;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/** Get connection stats */
|
|
388
|
-
getStats(): { clients: number; rooms: number; roomDetails: Record<string, number> } {
|
|
389
|
-
const roomDetails: Record<string, number> = {};
|
|
390
|
-
for (const [roomId, members] of this.rooms) {
|
|
391
|
-
roomDetails[roomId] = members.size;
|
|
392
|
-
}
|
|
393
|
-
return {
|
|
394
|
-
clients: this.clients.size,
|
|
395
|
-
rooms: this.rooms.size,
|
|
396
|
-
roomDetails,
|
|
397
|
-
};
|
|
398
|
-
}
|
|
399
|
-
}
|
|
1
|
+
import type { Message } from '../core/types';
|
|
2
|
+
import { BaseChannel } from './index';
|
|
3
|
+
import { WebSocketServer, type WebSocket } from 'ws';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* WebSocket Channel — v1.1.0
|
|
7
|
+
*
|
|
8
|
+
* Enhanced with:
|
|
9
|
+
* - Room support (multiple clients in a room)
|
|
10
|
+
* - Heartbeat/ping-pong to detect disconnected clients
|
|
11
|
+
* - Reconnection handling (session persistence)
|
|
12
|
+
* - Binary message support
|
|
13
|
+
* - Connection authentication (optional token in query string)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface WebSocketChannelConfig {
|
|
17
|
+
port?: number;
|
|
18
|
+
/** Heartbeat interval in ms (default: 30000) */
|
|
19
|
+
heartbeatInterval?: number;
|
|
20
|
+
/** Valid auth tokens (if empty, no auth required) */
|
|
21
|
+
authTokens?: string[];
|
|
22
|
+
/** Max clients per room (default: 100) */
|
|
23
|
+
maxClientsPerRoom?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ClientInfo {
|
|
27
|
+
ws: WebSocket;
|
|
28
|
+
sessionId: string;
|
|
29
|
+
rooms: Set<string>;
|
|
30
|
+
isAlive: boolean;
|
|
31
|
+
authenticated: boolean;
|
|
32
|
+
connectedAt: number;
|
|
33
|
+
lastMessageAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class WebSocketChannel extends BaseChannel {
|
|
37
|
+
readonly type = 'websocket';
|
|
38
|
+
private wss: WebSocketServer | null = null;
|
|
39
|
+
private config: Required<WebSocketChannelConfig>;
|
|
40
|
+
private clients: Map<string, ClientInfo> = new Map(); // sessionId -> ClientInfo
|
|
41
|
+
private rooms: Map<string, Set<string>> = new Map(); // roomId -> Set<sessionId>
|
|
42
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
43
|
+
|
|
44
|
+
constructor(configOrPort: number | WebSocketChannelConfig = 3002) {
|
|
45
|
+
super();
|
|
46
|
+
if (typeof configOrPort === 'number') {
|
|
47
|
+
this.config = { port: configOrPort, heartbeatInterval: 30000, authTokens: [], maxClientsPerRoom: 100 };
|
|
48
|
+
} else {
|
|
49
|
+
this.config = {
|
|
50
|
+
port: configOrPort.port ?? 3002,
|
|
51
|
+
heartbeatInterval: configOrPort.heartbeatInterval ?? 30000,
|
|
52
|
+
authTokens: configOrPort.authTokens ?? [],
|
|
53
|
+
maxClientsPerRoom: configOrPort.maxClientsPerRoom ?? 100,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async start(): Promise<void> {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
this.wss = new WebSocketServer({ port: this.config.port });
|
|
61
|
+
|
|
62
|
+
this.wss.on('connection', (ws, req) => {
|
|
63
|
+
const url = new URL(req.url ?? '/', `http://localhost:${this.config.port}`);
|
|
64
|
+
const token = url.searchParams.get('token');
|
|
65
|
+
const sessionId = url.searchParams.get('sessionId') ?? `ws_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
66
|
+
|
|
67
|
+
// Authentication check
|
|
68
|
+
if (this.config.authTokens.length > 0) {
|
|
69
|
+
if (!token || !this.config.authTokens.includes(token)) {
|
|
70
|
+
ws.close(4001, 'Unauthorized');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Handle reconnection: if sessionId already exists, replace the connection
|
|
76
|
+
const existing = this.clients.get(sessionId);
|
|
77
|
+
if (existing) {
|
|
78
|
+
try { existing.ws.close(4000, 'Replaced by new connection'); } catch {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const clientInfo: ClientInfo = {
|
|
82
|
+
ws,
|
|
83
|
+
sessionId,
|
|
84
|
+
rooms: existing?.rooms ?? new Set(),
|
|
85
|
+
isAlive: true,
|
|
86
|
+
authenticated: true,
|
|
87
|
+
connectedAt: Date.now(),
|
|
88
|
+
lastMessageAt: Date.now(),
|
|
89
|
+
};
|
|
90
|
+
this.clients.set(sessionId, clientInfo);
|
|
91
|
+
|
|
92
|
+
// Re-register in rooms after reconnect
|
|
93
|
+
for (const roomId of clientInfo.rooms) {
|
|
94
|
+
const room = this.rooms.get(roomId);
|
|
95
|
+
if (room) room.add(sessionId);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
ws.on('pong', () => {
|
|
99
|
+
clientInfo.isAlive = true;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
ws.on('message', async (data, isBinary) => {
|
|
103
|
+
clientInfo.lastMessageAt = Date.now();
|
|
104
|
+
clientInfo.isAlive = true;
|
|
105
|
+
|
|
106
|
+
if (isBinary) {
|
|
107
|
+
await this.handleBinaryMessage(clientInfo, data as Buffer);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(data.toString());
|
|
113
|
+
await this.handleTextMessage(clientInfo, parsed);
|
|
114
|
+
} catch {
|
|
115
|
+
ws.send(JSON.stringify({ error: 'Invalid message format' }));
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
ws.on('close', () => {
|
|
120
|
+
// Don't immediately remove - allow reconnection window
|
|
121
|
+
const info = this.clients.get(sessionId);
|
|
122
|
+
if (info && info.ws === ws) {
|
|
123
|
+
// Mark as disconnected but keep for potential reconnection
|
|
124
|
+
setTimeout(() => {
|
|
125
|
+
const current = this.clients.get(sessionId);
|
|
126
|
+
if (current && current.ws === ws) {
|
|
127
|
+
this.removeClient(sessionId);
|
|
128
|
+
}
|
|
129
|
+
}, 60000); // 60s reconnection window
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
ws.send(JSON.stringify({
|
|
134
|
+
type: 'connected',
|
|
135
|
+
sessionId,
|
|
136
|
+
timestamp: Date.now(),
|
|
137
|
+
}));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Start heartbeat
|
|
141
|
+
this.heartbeatTimer = setInterval(() => {
|
|
142
|
+
for (const [sessionId, info] of this.clients) {
|
|
143
|
+
if (!info.isAlive) {
|
|
144
|
+
try { info.ws.terminate(); } catch {}
|
|
145
|
+
this.removeClient(sessionId);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
info.isAlive = false;
|
|
149
|
+
try { info.ws.ping(); } catch {}
|
|
150
|
+
}
|
|
151
|
+
}, this.config.heartbeatInterval);
|
|
152
|
+
|
|
153
|
+
this.wss.on('listening', () => {
|
|
154
|
+
console.log(`[WebSocketChannel] Listening on port ${this.config.port}`);
|
|
155
|
+
resolve();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async stop(): Promise<void> {
|
|
161
|
+
if (this.heartbeatTimer) {
|
|
162
|
+
clearInterval(this.heartbeatTimer);
|
|
163
|
+
this.heartbeatTimer = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const [, info] of this.clients) {
|
|
167
|
+
try { info.ws.close(); } catch {}
|
|
168
|
+
}
|
|
169
|
+
this.clients.clear();
|
|
170
|
+
this.rooms.clear();
|
|
171
|
+
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
if (!this.wss) return resolve();
|
|
174
|
+
this.wss.close((err) => (err ? reject(err) : resolve()));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Handle text (JSON) messages */
|
|
179
|
+
private async handleTextMessage(client: ClientInfo, parsed: any): Promise<void> {
|
|
180
|
+
const type = parsed.type ?? 'message';
|
|
181
|
+
|
|
182
|
+
switch (type) {
|
|
183
|
+
case 'join': {
|
|
184
|
+
const roomId = parsed.room;
|
|
185
|
+
if (!roomId) {
|
|
186
|
+
client.ws.send(JSON.stringify({ error: 'room is required for join' }));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.joinRoom(client.sessionId, roomId);
|
|
190
|
+
client.ws.send(JSON.stringify({ type: 'joined', room: roomId, members: this.getRoomMembers(roomId).length }));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
case 'leave': {
|
|
195
|
+
const roomId = parsed.room;
|
|
196
|
+
if (roomId) {
|
|
197
|
+
this.leaveRoom(client.sessionId, roomId);
|
|
198
|
+
client.ws.send(JSON.stringify({ type: 'left', room: roomId }));
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
case 'room_message': {
|
|
204
|
+
const roomId = parsed.room;
|
|
205
|
+
const content = parsed.content ?? parsed.message;
|
|
206
|
+
if (roomId && content) {
|
|
207
|
+
this.broadcastToRoom(roomId, {
|
|
208
|
+
type: 'room_message',
|
|
209
|
+
room: roomId,
|
|
210
|
+
from: client.sessionId,
|
|
211
|
+
content,
|
|
212
|
+
timestamp: Date.now(),
|
|
213
|
+
}, client.sessionId);
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
case 'ping': {
|
|
219
|
+
client.ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
default: {
|
|
224
|
+
// Regular chat message
|
|
225
|
+
if (!this.handler) return;
|
|
226
|
+
|
|
227
|
+
const msg: Message = {
|
|
228
|
+
id: parsed.id ?? `ws_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
229
|
+
role: 'user',
|
|
230
|
+
content: parsed.content ?? parsed.message ?? JSON.stringify(parsed),
|
|
231
|
+
timestamp: Date.now(),
|
|
232
|
+
metadata: {
|
|
233
|
+
sessionId: client.sessionId,
|
|
234
|
+
platform: 'websocket',
|
|
235
|
+
room: parsed.room,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const response = await this.handler(msg);
|
|
240
|
+
client.ws.send(JSON.stringify({
|
|
241
|
+
id: response.id,
|
|
242
|
+
content: response.content,
|
|
243
|
+
timestamp: response.timestamp,
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Handle binary messages */
|
|
250
|
+
private async handleBinaryMessage(client: ClientInfo, data: Buffer): Promise<void> {
|
|
251
|
+
// Emit binary data with metadata
|
|
252
|
+
client.ws.send(JSON.stringify({
|
|
253
|
+
type: 'binary_ack',
|
|
254
|
+
size: data.length,
|
|
255
|
+
timestamp: Date.now(),
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
// If handler exists, pass as base64
|
|
259
|
+
if (this.handler) {
|
|
260
|
+
const msg: Message = {
|
|
261
|
+
id: `ws_bin_${Date.now()}`,
|
|
262
|
+
role: 'user',
|
|
263
|
+
content: `[binary:${data.length} bytes]`,
|
|
264
|
+
timestamp: Date.now(),
|
|
265
|
+
metadata: {
|
|
266
|
+
sessionId: client.sessionId,
|
|
267
|
+
platform: 'websocket',
|
|
268
|
+
binary: true,
|
|
269
|
+
binaryData: data.toString('base64'),
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
const response = await this.handler(msg);
|
|
273
|
+
client.ws.send(JSON.stringify({
|
|
274
|
+
id: response.id,
|
|
275
|
+
content: response.content,
|
|
276
|
+
timestamp: response.timestamp,
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Join a room */
|
|
282
|
+
joinRoom(sessionId: string, roomId: string): boolean {
|
|
283
|
+
const client = this.clients.get(sessionId);
|
|
284
|
+
if (!client) return false;
|
|
285
|
+
|
|
286
|
+
if (!this.rooms.has(roomId)) {
|
|
287
|
+
this.rooms.set(roomId, new Set());
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const room = this.rooms.get(roomId)!;
|
|
291
|
+
if (room.size >= this.config.maxClientsPerRoom) {
|
|
292
|
+
client.ws.send(JSON.stringify({ error: 'Room is full', room: roomId }));
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
room.add(sessionId);
|
|
297
|
+
client.rooms.add(roomId);
|
|
298
|
+
|
|
299
|
+
// Notify other room members
|
|
300
|
+
this.broadcastToRoom(roomId, {
|
|
301
|
+
type: 'member_joined',
|
|
302
|
+
room: roomId,
|
|
303
|
+
sessionId,
|
|
304
|
+
members: room.size,
|
|
305
|
+
timestamp: Date.now(),
|
|
306
|
+
}, sessionId);
|
|
307
|
+
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Leave a room */
|
|
312
|
+
leaveRoom(sessionId: string, roomId: string): void {
|
|
313
|
+
const client = this.clients.get(sessionId);
|
|
314
|
+
if (client) client.rooms.delete(roomId);
|
|
315
|
+
|
|
316
|
+
const room = this.rooms.get(roomId);
|
|
317
|
+
if (room) {
|
|
318
|
+
room.delete(sessionId);
|
|
319
|
+
if (room.size === 0) {
|
|
320
|
+
this.rooms.delete(roomId);
|
|
321
|
+
} else {
|
|
322
|
+
this.broadcastToRoom(roomId, {
|
|
323
|
+
type: 'member_left',
|
|
324
|
+
room: roomId,
|
|
325
|
+
sessionId,
|
|
326
|
+
members: room.size,
|
|
327
|
+
timestamp: Date.now(),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Remove client completely */
|
|
334
|
+
private removeClient(sessionId: string): void {
|
|
335
|
+
const client = this.clients.get(sessionId);
|
|
336
|
+
if (!client) return;
|
|
337
|
+
|
|
338
|
+
for (const roomId of client.rooms) {
|
|
339
|
+
this.leaveRoom(sessionId, roomId);
|
|
340
|
+
}
|
|
341
|
+
this.clients.delete(sessionId);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Get room member session IDs */
|
|
345
|
+
getRoomMembers(roomId: string): string[] {
|
|
346
|
+
return [...(this.rooms.get(roomId) ?? [])];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Get all rooms */
|
|
350
|
+
getRooms(): string[] {
|
|
351
|
+
return [...this.rooms.keys()];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Broadcast to all clients */
|
|
355
|
+
broadcast(content: string): void {
|
|
356
|
+
const msg = JSON.stringify({ type: 'broadcast', content, timestamp: Date.now() });
|
|
357
|
+
for (const [, info] of this.clients) {
|
|
358
|
+
if (info.ws.readyState === 1) {
|
|
359
|
+
info.ws.send(msg);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Broadcast to all clients in a room */
|
|
365
|
+
broadcastToRoom(roomId: string, data: any, excludeSessionId?: string): void {
|
|
366
|
+
const room = this.rooms.get(roomId);
|
|
367
|
+
if (!room) return;
|
|
368
|
+
|
|
369
|
+
const msg = typeof data === 'string' ? data : JSON.stringify(data);
|
|
370
|
+
for (const sessionId of room) {
|
|
371
|
+
if (sessionId === excludeSessionId) continue;
|
|
372
|
+
const client = this.clients.get(sessionId);
|
|
373
|
+
if (client && client.ws.readyState === 1) {
|
|
374
|
+
client.ws.send(msg);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Send to specific session */
|
|
380
|
+
sendToSession(sessionId: string, data: any): boolean {
|
|
381
|
+
const client = this.clients.get(sessionId);
|
|
382
|
+
if (!client || client.ws.readyState !== 1) return false;
|
|
383
|
+
client.ws.send(typeof data === 'string' ? data : JSON.stringify(data));
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Get connection stats */
|
|
388
|
+
getStats(): { clients: number; rooms: number; roomDetails: Record<string, number> } {
|
|
389
|
+
const roomDetails: Record<string, number> = {};
|
|
390
|
+
for (const [roomId, members] of this.rooms) {
|
|
391
|
+
roomDetails[roomId] = members.size;
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
clients: this.clients.size,
|
|
395
|
+
rooms: this.rooms.size,
|
|
396
|
+
roomDetails,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
}
|