chanjs 2.7.2 → 2.7.3
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/App.js +232 -16
- package/base/Aop.js +20 -3
- package/base/Container.js +80 -3
- package/base/Controller.js +38 -9
- package/base/Database.js +50 -0
- package/base/Event.js +12 -0
- package/base/{Service.js → Repository.js} +644 -539
- package/common/api.js +18 -8
- package/common/code.js +25 -15
- package/common/email.js +98 -17
- package/common/index.js +1 -1
- package/config/code.js +138 -82
- package/global/index.js +1 -1
- package/helper/index.js +43 -41
- package/index.js +19 -6
- package/loader/index.js +6 -0
- package/{helper → loader}/loader.js +41 -27
- package/middleware/compress.js +185 -0
- package/middleware/cors.js +36 -24
- package/middleware/header.js +5 -10
- package/middleware/index.js +1 -0
- package/middleware/log.js +27 -3
- package/middleware/setBody.js +9 -1
- package/middleware/static.js +2 -1
- package/middleware/template.js +139 -4
- package/middleware/waf.js +136 -76
- package/package.json +2 -3
- package/realtime/index.js +7 -0
- package/realtime/sse.js +424 -0
- package/realtime/websocket.js +540 -0
- package/response/index.js +12 -0
- package/response/response.js +258 -0
- package/schedule/index.js +6 -0
- package/schedule/schedule.js +491 -0
- package/{helper → security}/checker.js +23 -8
- package/security/index.js +14 -0
- package/{helper → security}/jwt.js +175 -107
- package/security/keywords.js +179 -0
- package/security/rate-limit.js +105 -0
- package/security/sign.js +210 -0
- package/security/xss-filter.js +63 -0
- package/storage/cache.js +258 -0
- package/storage/index.js +9 -0
- package/storage/redis.js +258 -0
- package/storage/store.js +266 -0
- package/{helper → utils}/file.js +106 -15
- package/{helper → utils}/filter.js +2 -1
- package/{helper → utils}/html.js +19 -1
- package/utils/index.js +34 -0
- package/{helper → utils}/ip.js +25 -16
- package/utils/request.js +172 -0
- package/{helper → utils}/time.js +1 -1
- package/utils/tree.js +121 -0
- package/common/category.js +0 -22
- package/common/sms.js +0 -104
- package/extend/art-template.js +0 -129
- package/extend/index.js +0 -6
- package/global/global.js +0 -63
- package/helper/cache.js +0 -187
- package/helper/keywords.js +0 -132
- package/helper/rate-limit.js +0 -116
- package/helper/request.js +0 -47
- package/helper/response.js +0 -180
- package/helper/sign.js +0 -96
- package/helper/tree.js +0 -77
- package/helper/xss-filter.js +0 -42
- /package/{helper → utils}/data-parse.js +0 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket 管理器(基于 ws 库)
|
|
3
|
+
*
|
|
4
|
+
* ============================================================
|
|
5
|
+
* 特性
|
|
6
|
+
* ============================================================
|
|
7
|
+
* 1. 基于 ws 库(业界标准),Node 原生 WebSocket 仅为客户端
|
|
8
|
+
* 2. 房间(Room)机制:分组广播,支持单推/房间广播/全局广播
|
|
9
|
+
* 3. 心跳保活:默认 30s ping,自动踢除无响应连接
|
|
10
|
+
* 4. 鉴权钩子:可选的 verifyClient,按业务校验握手请求
|
|
11
|
+
* 5. 消息路由:on(type, handler) 按消息 type 字段路由
|
|
12
|
+
* 6. 优雅停机:shutdown() 关闭所有连接与服务端
|
|
13
|
+
*
|
|
14
|
+
* ============================================================
|
|
15
|
+
* 使用方法
|
|
16
|
+
* ============================================================
|
|
17
|
+
*
|
|
18
|
+
* // 1. 在 App.js 启动时自动 attach(推荐,配置驱动)
|
|
19
|
+
* // config.WEBSOCKET = { enabled: true, path: '/ws' }
|
|
20
|
+
* // chan.run() 时框架自动调用 websocket.attach(chan.server)
|
|
21
|
+
*
|
|
22
|
+
* // 2. 业务侧注册消息处理器
|
|
23
|
+
* import { websocket } from 'chanjs';
|
|
24
|
+
* websocket.on('chat', (socket, payload) => {
|
|
25
|
+
* websocket.send(socket, { type: 'chat', data: 'echo: ' + payload.text });
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* // 3. 单推
|
|
29
|
+
* websocket.send(socketId, { type: 'notice', data: 'hello' });
|
|
30
|
+
*
|
|
31
|
+
* // 4. 广播到房间
|
|
32
|
+
* websocket.broadcast('room-1', { type: 'msg', data: 'hello room' });
|
|
33
|
+
*
|
|
34
|
+
* // 5. 全局广播
|
|
35
|
+
* websocket.broadcastAll({ type: 'ping', data: Date.now() });
|
|
36
|
+
*
|
|
37
|
+
* // 6. 鉴权(attach 时配置)
|
|
38
|
+
* websocket.attach(server, {
|
|
39
|
+
* path: '/ws',
|
|
40
|
+
* verifyClient: (info, cb) => {
|
|
41
|
+
* const ok = checkToken(info.req.headers.token);
|
|
42
|
+
* cb(ok ? undefined : new Error('auth failed'), ok ? 200 : 401);
|
|
43
|
+
* },
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* // 7. 状态查询
|
|
47
|
+
* websocket.getStatus(); // { connections, rooms, uptime }
|
|
48
|
+
*
|
|
49
|
+
* ============================================================
|
|
50
|
+
* 消息协议(建议)
|
|
51
|
+
* ============================================================
|
|
52
|
+
* 客户端发送:JSON 字符串 { type: 'chat', data: {...} }
|
|
53
|
+
* 服务端 send/broadcast:自动 JSON.stringify
|
|
54
|
+
* 心跳由 ws 库内置 ping/pong 机制处理,对业务透明
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { WebSocketServer } from 'ws';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 默认心跳间隔(ping 间隔)
|
|
61
|
+
*/
|
|
62
|
+
const DEFAULT_HEARTBEAT_INTERVAL = 30000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* WebSocket 管理器
|
|
66
|
+
* @class WebSocketManager
|
|
67
|
+
*/
|
|
68
|
+
class WebSocketManager {
|
|
69
|
+
constructor() {
|
|
70
|
+
/** @type {import('ws').WebSocketServer|null} ws 服务端 */
|
|
71
|
+
this._wss = null;
|
|
72
|
+
/** @type {Map<string, {socket: Object, rooms: Set<string>, joinedAt: number}>} 连接表 id -> client */
|
|
73
|
+
this._clients = new Map();
|
|
74
|
+
/** @type {Map<string, Set<string>>} 房间表 room -> Set<clientId> */
|
|
75
|
+
this._rooms = new Map();
|
|
76
|
+
/** @type {Map<string, Function>} 消息路由 type -> handler(socket, payload, clientId) */
|
|
77
|
+
this._handlers = new Map();
|
|
78
|
+
/** 心跳定时器 */
|
|
79
|
+
this._heartbeatTimer = null;
|
|
80
|
+
/** 心跳间隔 */
|
|
81
|
+
this._heartbeatInterval = DEFAULT_HEARTBEAT_INTERVAL;
|
|
82
|
+
/** 自增连接 id */
|
|
83
|
+
this._idSeq = 0;
|
|
84
|
+
/** 启动时间 */
|
|
85
|
+
this._startedAt = Date.now();
|
|
86
|
+
/** 是否已 shutdown */
|
|
87
|
+
this._shuttingDown = false;
|
|
88
|
+
/** 最大并发连接数上限,超出直接拒绝握手,防止连接洪泛耗尽内存 */
|
|
89
|
+
this._maxConnections = 10000;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 挂载到 HTTP 服务
|
|
94
|
+
* @param {Object} server - http.Server 实例(chan.server)
|
|
95
|
+
* @param {Object} [options]
|
|
96
|
+
* @param {string} [options.path] - 路径,默认 '/ws'
|
|
97
|
+
* @param {Function} [options.verifyClient] - 握手鉴权 (info, cb) => void
|
|
98
|
+
* @param {number} [options.heartbeatInterval] - 心跳间隔,默认 30000
|
|
99
|
+
* @param {Function} [options.onConnection] - 连接建立回调 (socket, req) => void
|
|
100
|
+
* @returns {Object} WebSocketServer 实例
|
|
101
|
+
*/
|
|
102
|
+
attach(server, options = {}) {
|
|
103
|
+
if (this._wss) {
|
|
104
|
+
throw new Error('[WebSocket] 已存在 server,请先 shutdown 再 attach');
|
|
105
|
+
}
|
|
106
|
+
const {
|
|
107
|
+
path = '/ws',
|
|
108
|
+
verifyClient,
|
|
109
|
+
heartbeatInterval,
|
|
110
|
+
onConnection,
|
|
111
|
+
} = options;
|
|
112
|
+
|
|
113
|
+
this._wss = new WebSocketServer({
|
|
114
|
+
noServer: true, // 不直接接管 server,手动处理 upgrade 以便与 Express 共存
|
|
115
|
+
verifyClient,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// 处理 upgrade 请求
|
|
119
|
+
// 1. 路径匹配 → 交给 ws 完成 WebSocket 握手
|
|
120
|
+
// 2. 路径不匹配 → 1 秒后断开,防止客户端挂起(其他模块可提前处理)
|
|
121
|
+
server.on('upgrade', (req, socket, head) => {
|
|
122
|
+
const url = new URL(req.url, 'http://localhost');
|
|
123
|
+
if (url.pathname === path) {
|
|
124
|
+
this._wss.handleUpgrade(req, socket, head, (ws) => {
|
|
125
|
+
this._wss.emit('connection', ws, req);
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// 非本模块路径:1 秒后兜底断开,避免连接挂起
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
try { socket.destroy(); } catch (e) {}
|
|
132
|
+
}, 1000);
|
|
133
|
+
socket.on('close', () => clearTimeout(timer));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// 连接建立
|
|
137
|
+
this._wss.on('connection', (socket, req) => {
|
|
138
|
+
this._handleConnection(socket, req);
|
|
139
|
+
onConnection?.(socket, req);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// 启动心跳
|
|
143
|
+
if (typeof heartbeatInterval === 'number' && heartbeatInterval > 0) {
|
|
144
|
+
this._heartbeatInterval = heartbeatInterval;
|
|
145
|
+
}
|
|
146
|
+
this._ensureHeartbeat();
|
|
147
|
+
|
|
148
|
+
console.log(`[WebSocket] 已挂载到路径 ${path}`);
|
|
149
|
+
return this._wss;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 注册消息处理器
|
|
154
|
+
* @param {string} type - 消息类型(对应 payload.type)
|
|
155
|
+
* @param {Function} handler - (socket, payload, clientId) => void
|
|
156
|
+
* @example
|
|
157
|
+
* websocket.on('chat', (socket, payload, clientId) => {
|
|
158
|
+
* websocket.send(socket, { type: 'chat', data: payload.text });
|
|
159
|
+
* });
|
|
160
|
+
*/
|
|
161
|
+
on(type, handler) {
|
|
162
|
+
if (typeof type !== 'string' || typeof handler !== 'function') {
|
|
163
|
+
throw new Error('[WebSocket] on(type, handler) 参数无效');
|
|
164
|
+
}
|
|
165
|
+
this._handlers.set(type, handler);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 移除消息处理器
|
|
170
|
+
* @param {string} type
|
|
171
|
+
*/
|
|
172
|
+
off(type) {
|
|
173
|
+
return this._handlers.delete(type);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 将连接加入房间
|
|
178
|
+
* @param {Object|string} target - socket 或 clientId
|
|
179
|
+
* @param {string} room
|
|
180
|
+
* @returns {string|null} clientId
|
|
181
|
+
*/
|
|
182
|
+
join(target, room) {
|
|
183
|
+
const clientId = this._resolveClientId(target);
|
|
184
|
+
if (!clientId) return null;
|
|
185
|
+
const client = this._clients.get(clientId);
|
|
186
|
+
if (!client) return null;
|
|
187
|
+
|
|
188
|
+
client.rooms.add(room);
|
|
189
|
+
if (!this._rooms.has(room)) this._rooms.set(room, new Set());
|
|
190
|
+
this._rooms.get(room).add(clientId);
|
|
191
|
+
return clientId;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 离开房间
|
|
196
|
+
* @param {Object|string} target
|
|
197
|
+
* @param {string} room
|
|
198
|
+
* @returns {boolean}
|
|
199
|
+
*/
|
|
200
|
+
leave(target, room) {
|
|
201
|
+
const clientId = this._resolveClientId(target);
|
|
202
|
+
if (!clientId) return false;
|
|
203
|
+
const client = this._clients.get(clientId);
|
|
204
|
+
if (!client) return false;
|
|
205
|
+
|
|
206
|
+
client.rooms.delete(room);
|
|
207
|
+
const members = this._rooms.get(room);
|
|
208
|
+
if (members) {
|
|
209
|
+
members.delete(clientId);
|
|
210
|
+
if (members.size === 0) this._rooms.delete(room);
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 向单个连接发送消息
|
|
217
|
+
* @param {Object|string} target - socket 或 clientId
|
|
218
|
+
* @param {*} data - 任意可序列化数据
|
|
219
|
+
* @returns {boolean} 是否发送成功
|
|
220
|
+
*/
|
|
221
|
+
send(target, data) {
|
|
222
|
+
const clientId = this._resolveClientId(target);
|
|
223
|
+
if (!clientId) return false;
|
|
224
|
+
const client = this._clients.get(clientId);
|
|
225
|
+
if (!client) return false;
|
|
226
|
+
|
|
227
|
+
return this._safeSend(client.socket, data);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 广播到指定房间
|
|
232
|
+
* @param {string} room
|
|
233
|
+
* @param {*} data
|
|
234
|
+
* @param {Object} [options]
|
|
235
|
+
* @param {Object} [options.exclude] - 排除的 socket 或 clientId
|
|
236
|
+
* @returns {number} 实际送达数
|
|
237
|
+
*/
|
|
238
|
+
broadcast(room, data, options = {}) {
|
|
239
|
+
const members = this._rooms.get(room);
|
|
240
|
+
if (!members || members.size === 0) return 0;
|
|
241
|
+
|
|
242
|
+
const excludeId = options.exclude
|
|
243
|
+
? this._resolveClientId(options.exclude)
|
|
244
|
+
: null;
|
|
245
|
+
|
|
246
|
+
let sent = 0;
|
|
247
|
+
for (const clientId of members) {
|
|
248
|
+
if (clientId === excludeId) continue;
|
|
249
|
+
if (this.send(clientId, data)) sent++;
|
|
250
|
+
}
|
|
251
|
+
return sent;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 广播到所有连接
|
|
256
|
+
* @param {*} data
|
|
257
|
+
* @param {Object} [options]
|
|
258
|
+
* @param {Object} [options.exclude] - 排除的 socket 或 clientId
|
|
259
|
+
* @returns {number}
|
|
260
|
+
*/
|
|
261
|
+
broadcastAll(data, options = {}) {
|
|
262
|
+
const excludeId = options.exclude
|
|
263
|
+
? this._resolveClientId(options.exclude)
|
|
264
|
+
: null;
|
|
265
|
+
|
|
266
|
+
let sent = 0;
|
|
267
|
+
for (const clientId of this._clients.keys()) {
|
|
268
|
+
if (clientId === excludeId) continue;
|
|
269
|
+
if (this.send(clientId, data)) sent++;
|
|
270
|
+
}
|
|
271
|
+
return sent;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 获取当前连接数
|
|
276
|
+
* @returns {number}
|
|
277
|
+
*/
|
|
278
|
+
size() {
|
|
279
|
+
return this._clients.size;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 获取指定房间连接数
|
|
284
|
+
* @param {string} room
|
|
285
|
+
* @returns {number}
|
|
286
|
+
*/
|
|
287
|
+
roomSize(room) {
|
|
288
|
+
return this._rooms.get(room)?.size || 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 获取运行状态
|
|
293
|
+
* @returns {Object} { connections, rooms, uptime, path }
|
|
294
|
+
*/
|
|
295
|
+
getStatus() {
|
|
296
|
+
return {
|
|
297
|
+
connections: this._clients.size,
|
|
298
|
+
rooms: this._rooms.size,
|
|
299
|
+
roomList: Array.from(this._rooms.keys()),
|
|
300
|
+
path: this._wss?.options?.path || null,
|
|
301
|
+
uptime: Date.now() - this._startedAt,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 优雅停机
|
|
307
|
+
*/
|
|
308
|
+
shutdown() {
|
|
309
|
+
this._shuttingDown = true;
|
|
310
|
+
|
|
311
|
+
// 停止心跳
|
|
312
|
+
if (this._heartbeatTimer) {
|
|
313
|
+
clearInterval(this._heartbeatTimer);
|
|
314
|
+
this._heartbeatTimer = null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 关闭所有客户端连接
|
|
318
|
+
for (const [, client] of this._clients) {
|
|
319
|
+
try {
|
|
320
|
+
client.socket.terminate();
|
|
321
|
+
} catch (e) {
|
|
322
|
+
// 忽略
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
this._clients.clear();
|
|
326
|
+
this._rooms.clear();
|
|
327
|
+
|
|
328
|
+
// 关闭 ws 服务端
|
|
329
|
+
if (this._wss) {
|
|
330
|
+
try {
|
|
331
|
+
this._wss.close();
|
|
332
|
+
} catch (e) {
|
|
333
|
+
// 忽略
|
|
334
|
+
}
|
|
335
|
+
this._wss = null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
console.log(`[WebSocket] 已关闭所有 WebSocket 连接`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ============================================================
|
|
342
|
+
// 私有方法
|
|
343
|
+
// ============================================================
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 处理新连接
|
|
347
|
+
* @private
|
|
348
|
+
*/
|
|
349
|
+
_handleConnection(socket, req) {
|
|
350
|
+
if (this._shuttingDown) {
|
|
351
|
+
socket.terminate();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 连接数上限校验:超出直接拒绝握手,防止连接洪泛
|
|
356
|
+
if (this._clients.size >= this._maxConnections) {
|
|
357
|
+
console.error(`[WebSocket] 连接数已达上限 (${this._maxConnections}),拒绝新连接`);
|
|
358
|
+
try {
|
|
359
|
+
socket.close(1013, 'Maximum connections reached'); // 1013 = Try Again Later
|
|
360
|
+
} catch (e) {}
|
|
361
|
+
socket.terminate();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const clientId = `ws_${++this._idSeq}_${Date.now()}`;
|
|
366
|
+
this._clients.set(clientId, {
|
|
367
|
+
socket,
|
|
368
|
+
rooms: new Set(),
|
|
369
|
+
joinedAt: Date.now(),
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// 注入到 socket 便于业务侧使用
|
|
373
|
+
socket.clientId = clientId;
|
|
374
|
+
socket.join = (room) => this.join(socket, room);
|
|
375
|
+
socket.leave = (room) => this.leave(socket, room);
|
|
376
|
+
// 保存原生 send,再覆盖为业务便捷方法(避免 _safeSend 递归调用)
|
|
377
|
+
socket._rawSend = socket.send.bind(socket);
|
|
378
|
+
socket.send = (data) => this.send(socket, data);
|
|
379
|
+
socket.broadcast = (room, data) => this.broadcast(room, data, { exclude: socket });
|
|
380
|
+
|
|
381
|
+
// 消息路由
|
|
382
|
+
socket.on('message', (raw) => {
|
|
383
|
+
this._handleMessage(socket, clientId, raw);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// 连接关闭
|
|
387
|
+
socket.on('close', () => {
|
|
388
|
+
this._removeClient(clientId);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
socket.on('error', (err) => {
|
|
392
|
+
console.error(`[WebSocket] 连接 ${clientId} 错误:`, err.message);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// pong 响应(心跳保活)
|
|
396
|
+
socket.on('pong', () => {
|
|
397
|
+
socket._isAlive = true;
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
socket._isAlive = true;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* 处理收到的消息
|
|
405
|
+
* @private
|
|
406
|
+
*/
|
|
407
|
+
_handleMessage(socket, clientId, raw) {
|
|
408
|
+
let payload;
|
|
409
|
+
try {
|
|
410
|
+
payload = JSON.parse(raw.toString());
|
|
411
|
+
} catch (e) {
|
|
412
|
+
// 非 JSON 消息,作为 raw 类型分发
|
|
413
|
+
payload = { type: 'raw', data: raw.toString() };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const type = payload?.type;
|
|
417
|
+
const handler = type ? this._handlers.get(type) : null;
|
|
418
|
+
|
|
419
|
+
if (handler) {
|
|
420
|
+
try {
|
|
421
|
+
handler(socket, payload, clientId);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
console.error(`[WebSocket] 处理器 ${type} 异常:`, err.message);
|
|
424
|
+
}
|
|
425
|
+
} else if (this._handlers.has('*')) {
|
|
426
|
+
// 通配符处理器
|
|
427
|
+
try {
|
|
428
|
+
this._handlers.get('*')(socket, payload, clientId);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
console.error(`[WebSocket] 通配符处理器异常:`, err.message);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// 无匹配处理器时静默丢弃,避免消息洪泛
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* 安全发送(处理连接已关闭/异常)
|
|
438
|
+
* @private
|
|
439
|
+
*/
|
|
440
|
+
_safeSend(socket, data) {
|
|
441
|
+
if (socket.readyState !== 1 /* OPEN */) return false;
|
|
442
|
+
try {
|
|
443
|
+
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
|
444
|
+
// 用原生 send(_rawSend),避免调用被覆盖的 socket.send 导致递归
|
|
445
|
+
const sendFn = socket._rawSend || socket.send.bind(socket);
|
|
446
|
+
sendFn(payload);
|
|
447
|
+
return true;
|
|
448
|
+
} catch (e) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* 启动心跳(ping 检测,无响应则踢除)
|
|
455
|
+
* @private
|
|
456
|
+
*/
|
|
457
|
+
_ensureHeartbeat() {
|
|
458
|
+
if (this._heartbeatTimer) return;
|
|
459
|
+
|
|
460
|
+
this._heartbeatTimer = setInterval(() => {
|
|
461
|
+
if (this._clients.size === 0) return;
|
|
462
|
+
|
|
463
|
+
for (const [clientId, client] of this._clients) {
|
|
464
|
+
if (client.socket._isAlive === false) {
|
|
465
|
+
// 上次 ping 无响应,踢除
|
|
466
|
+
client.socket.terminate();
|
|
467
|
+
this._removeClient(clientId);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
client.socket._isAlive = false;
|
|
471
|
+
try {
|
|
472
|
+
client.socket.ping();
|
|
473
|
+
} catch (e) {
|
|
474
|
+
// 忽略
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}, this._heartbeatInterval);
|
|
478
|
+
|
|
479
|
+
if (this._heartbeatTimer.unref) this._heartbeatTimer.unref();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* 移除客户端(清理房间索引)
|
|
484
|
+
* @private
|
|
485
|
+
*/
|
|
486
|
+
_removeClient(clientId) {
|
|
487
|
+
const client = this._clients.get(clientId);
|
|
488
|
+
if (!client) return;
|
|
489
|
+
|
|
490
|
+
for (const room of client.rooms) {
|
|
491
|
+
const members = this._rooms.get(room);
|
|
492
|
+
if (members) {
|
|
493
|
+
members.delete(clientId);
|
|
494
|
+
if (members.size === 0) this._rooms.delete(room);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
this._clients.delete(clientId);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* 解析 clientId(接受 socket 或 clientId)
|
|
502
|
+
* @private
|
|
503
|
+
*/
|
|
504
|
+
_resolveClientId(target) {
|
|
505
|
+
if (typeof target === 'string') return target;
|
|
506
|
+
if (target?.clientId) return target.clientId;
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// 全局单例(懒加载:首次访问属性时才实例化,避免 import 即实例化浪费内存)
|
|
512
|
+
let _wsInstance = null;
|
|
513
|
+
const getWSInstance = () => {
|
|
514
|
+
if (!_wsInstance) _wsInstance = new WebSocketManager();
|
|
515
|
+
return _wsInstance;
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// 用 Proxy 保持 `import { websocket } from 'chanjs'` API 兼容,访问任意属性时才实例化
|
|
519
|
+
const websocket = new Proxy(
|
|
520
|
+
{},
|
|
521
|
+
{
|
|
522
|
+
get(_target, prop) {
|
|
523
|
+
const inst = getWSInstance();
|
|
524
|
+
const val = inst[prop];
|
|
525
|
+
return typeof val === 'function' ? val.bind(inst) : val;
|
|
526
|
+
},
|
|
527
|
+
set(_target, prop, value) {
|
|
528
|
+
getWSInstance()[prop] = value;
|
|
529
|
+
return true;
|
|
530
|
+
},
|
|
531
|
+
has(_target, prop) {
|
|
532
|
+
return prop in getWSInstance();
|
|
533
|
+
},
|
|
534
|
+
getPrototypeOf() {
|
|
535
|
+
return WebSocketManager.prototype;
|
|
536
|
+
},
|
|
537
|
+
}
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
export { websocket as default, websocket, WebSocketManager, DEFAULT_HEARTBEAT_INTERVAL as WS_DEFAULT_HEARTBEAT_INTERVAL };
|