@forinda/kickjs-ws 5.1.0 → 5.2.1
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/dist/index.mjs +4 -525
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs-ws v5.1
|
|
2
|
+
* @forinda/kickjs-ws v5.2.1
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -8,528 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//#region src/interfaces.ts
|
|
15
|
-
const WS_METADATA = {
|
|
16
|
-
WS_CONTROLLER: "kick/ws/controller",
|
|
17
|
-
WS_HANDLERS: "kick/ws/handlers"
|
|
18
|
-
};
|
|
19
|
-
/** DI token for the live {@link WsAdapter} instance. */
|
|
20
|
-
const WS_ADAPTER = createToken("kick/ws/Adapter");
|
|
21
|
-
/** DI token for the shared {@link RoomManager}. */
|
|
22
|
-
const WS_ROOM_MANAGER = createToken("kick/ws/RoomManager");
|
|
23
|
-
/** DI token for the per-user broadcaster helper. */
|
|
24
|
-
const WS_USER_BROADCASTER = createToken("kick/ws/UserBroadcaster");
|
|
25
|
-
/** Registry of all @WsController classes — populated at decorator time */
|
|
26
|
-
const wsControllerRegistry = /* @__PURE__ */ new Set();
|
|
27
|
-
//#endregion
|
|
28
|
-
//#region src/ws-context.ts
|
|
29
|
-
/**
|
|
30
|
-
* Context object passed to WebSocket handler methods.
|
|
31
|
-
* Analogous to RequestContext for HTTP controllers.
|
|
32
|
-
*
|
|
33
|
-
* @example
|
|
34
|
-
* ```ts
|
|
35
|
-
* @OnMessage('chat:send')
|
|
36
|
-
* handleSend(ctx: WsContext) {
|
|
37
|
-
* console.log(ctx.data) // parsed message payload
|
|
38
|
-
* ctx.send('chat:ack', { ok: true })
|
|
39
|
-
* ctx.broadcast('chat:receive', ctx.data)
|
|
40
|
-
* ctx.join('room-1')
|
|
41
|
-
* ctx.to('room-1').send('chat:receive', ctx.data)
|
|
42
|
-
* }
|
|
43
|
-
* ```
|
|
44
|
-
*/
|
|
45
|
-
var WsContext = class {
|
|
46
|
-
/** Unique connection ID */
|
|
47
|
-
id;
|
|
48
|
-
/** Parsed message payload (set for @OnMessage handlers) */
|
|
49
|
-
data;
|
|
50
|
-
/** Event name from the message envelope (set for @OnMessage handlers) */
|
|
51
|
-
event;
|
|
52
|
-
/** The namespace this connection belongs to */
|
|
53
|
-
namespace;
|
|
54
|
-
/** The HTTP upgrade request — available from @OnConnect onward. Use to read
|
|
55
|
-
* cookies, headers, query string, and client IP for authenticated handshakes. */
|
|
56
|
-
request;
|
|
57
|
-
metadata = /* @__PURE__ */ new Map();
|
|
58
|
-
constructor(socket, server, roomManager, namespaceSockets, id, namespace, request) {
|
|
59
|
-
this.socket = socket;
|
|
60
|
-
this.server = server;
|
|
61
|
-
this.roomManager = roomManager;
|
|
62
|
-
this.namespaceSockets = namespaceSockets;
|
|
63
|
-
this.id = id;
|
|
64
|
-
this.namespace = namespace;
|
|
65
|
-
this.request = request;
|
|
66
|
-
this.data = null;
|
|
67
|
-
this.event = "";
|
|
68
|
-
}
|
|
69
|
-
/** Parsed cookies from the upgrade request (raw `Cookie` header parse). */
|
|
70
|
-
get cookies() {
|
|
71
|
-
const header = this.request.headers.cookie;
|
|
72
|
-
if (!header) return {};
|
|
73
|
-
const out = {};
|
|
74
|
-
for (const part of header.split(";")) {
|
|
75
|
-
const idx = part.indexOf("=");
|
|
76
|
-
if (idx === -1) continue;
|
|
77
|
-
const k = part.slice(0, idx).trim();
|
|
78
|
-
const v = part.slice(idx + 1).trim();
|
|
79
|
-
if (k) out[k] = decodeURIComponent(v);
|
|
80
|
-
}
|
|
81
|
-
return out;
|
|
82
|
-
}
|
|
83
|
-
/** Get a metadata value */
|
|
84
|
-
get(key) {
|
|
85
|
-
return this.metadata.get(key);
|
|
86
|
-
}
|
|
87
|
-
/** Set a metadata value (persists for the lifetime of the connection) */
|
|
88
|
-
set(key, value) {
|
|
89
|
-
this.metadata.set(key, value);
|
|
90
|
-
}
|
|
91
|
-
/** Send a message to this socket */
|
|
92
|
-
send(event, data) {
|
|
93
|
-
if (this.socket.readyState === this.socket.OPEN) this.socket.send(JSON.stringify({
|
|
94
|
-
event,
|
|
95
|
-
data
|
|
96
|
-
}));
|
|
97
|
-
}
|
|
98
|
-
/** Send to all sockets in the same namespace except this one */
|
|
99
|
-
broadcast(event, data) {
|
|
100
|
-
const message = JSON.stringify({
|
|
101
|
-
event,
|
|
102
|
-
data
|
|
103
|
-
});
|
|
104
|
-
for (const [id, socket] of this.namespaceSockets) if (id !== this.id && socket.readyState === socket.OPEN) socket.send(message);
|
|
105
|
-
}
|
|
106
|
-
/** Send to all sockets in the same namespace including this one */
|
|
107
|
-
broadcastAll(event, data) {
|
|
108
|
-
const message = JSON.stringify({
|
|
109
|
-
event,
|
|
110
|
-
data
|
|
111
|
-
});
|
|
112
|
-
for (const [, socket] of this.namespaceSockets) if (socket.readyState === socket.OPEN) socket.send(message);
|
|
113
|
-
}
|
|
114
|
-
/** Join a room */
|
|
115
|
-
join(room) {
|
|
116
|
-
this.roomManager.join(this.id, this.socket, room);
|
|
117
|
-
}
|
|
118
|
-
/** Leave a room */
|
|
119
|
-
leave(room) {
|
|
120
|
-
this.roomManager.leave(this.id, room);
|
|
121
|
-
}
|
|
122
|
-
/** Get all rooms this socket is in */
|
|
123
|
-
rooms() {
|
|
124
|
-
return this.roomManager.getRooms(this.id);
|
|
125
|
-
}
|
|
126
|
-
/** Send to all sockets in a room */
|
|
127
|
-
to(room) {
|
|
128
|
-
return { send: (event, data) => {
|
|
129
|
-
this.roomManager.broadcast(room, event, data);
|
|
130
|
-
} };
|
|
131
|
-
}
|
|
132
|
-
};
|
|
133
|
-
//#endregion
|
|
134
|
-
//#region src/room-manager.ts
|
|
135
|
-
/**
|
|
136
|
-
* Manages WebSocket room membership and broadcasting.
|
|
137
|
-
* Standalone from ws/socket.io — can be swapped for socket.io's built-in rooms.
|
|
138
|
-
*/
|
|
139
|
-
var RoomManager = class {
|
|
140
|
-
/** socketId → set of room names */
|
|
141
|
-
socketRooms = /* @__PURE__ */ new Map();
|
|
142
|
-
/** room name → set of { socketId, socket } */
|
|
143
|
-
roomSockets = /* @__PURE__ */ new Map();
|
|
144
|
-
join(socketId, socket, room) {
|
|
145
|
-
if (!this.socketRooms.has(socketId)) this.socketRooms.set(socketId, /* @__PURE__ */ new Set());
|
|
146
|
-
this.socketRooms.get(socketId).add(room);
|
|
147
|
-
if (!this.roomSockets.has(room)) this.roomSockets.set(room, /* @__PURE__ */ new Map());
|
|
148
|
-
this.roomSockets.get(room).set(socketId, socket);
|
|
149
|
-
}
|
|
150
|
-
leave(socketId, room) {
|
|
151
|
-
this.socketRooms.get(socketId)?.delete(room);
|
|
152
|
-
this.roomSockets.get(room)?.delete(socketId);
|
|
153
|
-
if (this.roomSockets.get(room)?.size === 0) this.roomSockets.delete(room);
|
|
154
|
-
}
|
|
155
|
-
/** Remove socket from all rooms (called on disconnect) */
|
|
156
|
-
leaveAll(socketId) {
|
|
157
|
-
const rooms = this.socketRooms.get(socketId);
|
|
158
|
-
if (rooms) for (const room of rooms) {
|
|
159
|
-
this.roomSockets.get(room)?.delete(socketId);
|
|
160
|
-
if (this.roomSockets.get(room)?.size === 0) this.roomSockets.delete(room);
|
|
161
|
-
}
|
|
162
|
-
this.socketRooms.delete(socketId);
|
|
163
|
-
}
|
|
164
|
-
getRooms(socketId) {
|
|
165
|
-
return Array.from(this.socketRooms.get(socketId) ?? []);
|
|
166
|
-
}
|
|
167
|
-
getSockets(room) {
|
|
168
|
-
return this.roomSockets.get(room) ?? /* @__PURE__ */ new Map();
|
|
169
|
-
}
|
|
170
|
-
/** Get all rooms with their member counts */
|
|
171
|
-
getAllRooms() {
|
|
172
|
-
const result = {};
|
|
173
|
-
for (const [room, sockets] of this.roomSockets) result[room] = sockets.size;
|
|
174
|
-
return result;
|
|
175
|
-
}
|
|
176
|
-
/** Broadcast to all sockets in a room, optionally excluding one */
|
|
177
|
-
broadcast(room, event, data, excludeId) {
|
|
178
|
-
const sockets = this.roomSockets.get(room);
|
|
179
|
-
if (!sockets) return;
|
|
180
|
-
const message = JSON.stringify({
|
|
181
|
-
event,
|
|
182
|
-
data
|
|
183
|
-
});
|
|
184
|
-
for (const [id, socket] of sockets) if (id !== excludeId && socket.readyState === socket.OPEN) socket.send(message);
|
|
185
|
-
}
|
|
186
|
-
};
|
|
187
|
-
//#endregion
|
|
188
|
-
//#region src/ws-adapter.ts
|
|
189
|
-
const log = createLogger("WsAdapter");
|
|
190
|
-
/**
|
|
191
|
-
* WebSocket adapter for KickJS. Attaches to the HTTP server and routes
|
|
192
|
-
* WebSocket connections to @WsController classes based on namespace paths.
|
|
193
|
-
*
|
|
194
|
-
* @example
|
|
195
|
-
* ```ts
|
|
196
|
-
* import { WsAdapter } from '@forinda/kickjs-ws'
|
|
197
|
-
*
|
|
198
|
-
* bootstrap({
|
|
199
|
-
* modules: [ChatModule],
|
|
200
|
-
* adapters: [
|
|
201
|
-
* WsAdapter({ path: '/ws' }),
|
|
202
|
-
* ],
|
|
203
|
-
* })
|
|
204
|
-
* ```
|
|
205
|
-
*
|
|
206
|
-
* Clients connect to: `ws://localhost:3000/ws/chat`
|
|
207
|
-
* Messages are JSON: `{ "event": "send", "data": { "text": "hello" } }`
|
|
208
|
-
*/
|
|
209
|
-
const WsAdapter = defineAdapter({
|
|
210
|
-
name: "WsAdapter",
|
|
211
|
-
defaults: {
|
|
212
|
-
path: "/ws",
|
|
213
|
-
heartbeatInterval: 3e4
|
|
214
|
-
},
|
|
215
|
-
build: (options) => {
|
|
216
|
-
const basePath = options.path;
|
|
217
|
-
const heartbeatInterval = options.heartbeatInterval;
|
|
218
|
-
const maxPayload = options.maxPayload;
|
|
219
|
-
const auth = options.auth;
|
|
220
|
-
const userRoomPrefix = options.auth?.userRoomPrefix ?? "user:";
|
|
221
|
-
let wss = null;
|
|
222
|
-
let container = null;
|
|
223
|
-
const namespaces = /* @__PURE__ */ new Map();
|
|
224
|
-
const roomManager = new RoomManager();
|
|
225
|
-
let heartbeatTimer = null;
|
|
226
|
-
const totalConnections = ref(0);
|
|
227
|
-
const activeConnections = ref(0);
|
|
228
|
-
const messagesReceived = ref(0);
|
|
229
|
-
const messagesSent = ref(0);
|
|
230
|
-
const wsErrors = ref(0);
|
|
231
|
-
const userRoom = (userId) => userRoomPrefix + userId;
|
|
232
|
-
const broadcastToUser = (userId, event, data) => {
|
|
233
|
-
roomManager.broadcast(userRoom(userId), event, data);
|
|
234
|
-
};
|
|
235
|
-
const buildUserBroadcaster = () => ({
|
|
236
|
-
roomFor: (id) => userRoom(id),
|
|
237
|
-
broadcastToUser: (id, event, data) => broadcastToUser(id, event, data),
|
|
238
|
-
toUser: (id) => ({ send: (event, data) => broadcastToUser(id, event, data) })
|
|
239
|
-
});
|
|
240
|
-
const getStats = () => {
|
|
241
|
-
const namespaceStats = {};
|
|
242
|
-
for (const [path, entry] of namespaces) namespaceStats[path] = {
|
|
243
|
-
connections: entry.sockets.size,
|
|
244
|
-
handlers: entry.handlers.length
|
|
245
|
-
};
|
|
246
|
-
return {
|
|
247
|
-
totalConnections: totalConnections.value,
|
|
248
|
-
activeConnections: activeConnections.value,
|
|
249
|
-
messagesReceived: messagesReceived.value,
|
|
250
|
-
messagesSent: messagesSent.value,
|
|
251
|
-
errors: wsErrors.value,
|
|
252
|
-
namespaces: namespaceStats,
|
|
253
|
-
rooms: roomManager.getAllRooms()
|
|
254
|
-
};
|
|
255
|
-
};
|
|
256
|
-
const safeInvoke = (controller, method, ctx) => {
|
|
257
|
-
try {
|
|
258
|
-
const result = controller[method](ctx);
|
|
259
|
-
if (result instanceof Promise) result.catch((err) => {
|
|
260
|
-
log.error({ err }, `WS handler error in ${method}`);
|
|
261
|
-
});
|
|
262
|
-
} catch (err) {
|
|
263
|
-
log.error({ err }, `WS handler error in ${method}`);
|
|
264
|
-
}
|
|
265
|
-
};
|
|
266
|
-
const invokeHandlers = (controller, handlers, type, ctx) => {
|
|
267
|
-
for (const handler of handlers) if (handler.type === type) safeInvoke(controller, handler.handlerName, ctx);
|
|
268
|
-
};
|
|
269
|
-
/**
|
|
270
|
-
* Runs the configured auth hook against the upgrade request. Stashes the
|
|
271
|
-
* resolved user on the context (as `user`, plus mirrored keys) and, when
|
|
272
|
-
* `autoJoinUserRoom` is enabled, joins the socket to `user:<id>`.
|
|
273
|
-
* Returns `false` (and closes the socket with code 4401) on failure.
|
|
274
|
-
*/
|
|
275
|
-
const authenticate = async (ctx) => {
|
|
276
|
-
if (!auth) return true;
|
|
277
|
-
try {
|
|
278
|
-
const user = await auth.resolveUser(ctx.request);
|
|
279
|
-
if (!user || !user.id) {
|
|
280
|
-
ctx.socket.close(4401, "Unauthorized");
|
|
281
|
-
return false;
|
|
282
|
-
}
|
|
283
|
-
ctx.set("user", user);
|
|
284
|
-
ctx.set("userId", user.id);
|
|
285
|
-
if (auth.autoJoinUserRoom !== false) ctx.join(userRoom(user.id));
|
|
286
|
-
return true;
|
|
287
|
-
} catch (err) {
|
|
288
|
-
log.warn("WS auth failed", err);
|
|
289
|
-
ctx.socket.close(4401, "Unauthorized");
|
|
290
|
-
return false;
|
|
291
|
-
}
|
|
292
|
-
};
|
|
293
|
-
const handleConnection = (ws, entry, request) => {
|
|
294
|
-
const socketId = randomUUID();
|
|
295
|
-
ws.__alive = true;
|
|
296
|
-
entry.sockets.set(socketId, ws);
|
|
297
|
-
totalConnections.value++;
|
|
298
|
-
activeConnections.value++;
|
|
299
|
-
const ctx = new WsContext(ws, wss, roomManager, entry.sockets, socketId, entry.namespace, request);
|
|
300
|
-
entry.contexts.set(socketId, ctx);
|
|
301
|
-
const controller = container.resolve(entry.controllerClass);
|
|
302
|
-
ws.on("pong", () => {
|
|
303
|
-
ws.__alive = true;
|
|
304
|
-
});
|
|
305
|
-
let authed = auth === void 0;
|
|
306
|
-
(auth ? authenticate(ctx) : Promise.resolve(true)).then((ok) => {
|
|
307
|
-
if (!ok) return;
|
|
308
|
-
authed = true;
|
|
309
|
-
invokeHandlers(controller, entry.handlers, "connect", ctx);
|
|
310
|
-
});
|
|
311
|
-
ws.on("message", (raw) => {
|
|
312
|
-
if (!authed) return;
|
|
313
|
-
messagesReceived.value++;
|
|
314
|
-
try {
|
|
315
|
-
const parsed = JSON.parse(raw.toString());
|
|
316
|
-
const event = parsed.event;
|
|
317
|
-
const data = parsed.data;
|
|
318
|
-
if (!event || typeof event !== "string") {
|
|
319
|
-
ctx.send("error", { message: "Invalid message format: missing \"event\" field" });
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
ctx.event = event;
|
|
323
|
-
ctx.data = data;
|
|
324
|
-
const handler = entry.handlers.find((h) => h.type === "message" && h.event === event);
|
|
325
|
-
if (handler) safeInvoke(controller, handler.handlerName, ctx);
|
|
326
|
-
else {
|
|
327
|
-
const catchAll = entry.handlers.find((h) => h.type === "message" && h.event === "*");
|
|
328
|
-
if (catchAll) safeInvoke(controller, catchAll.handlerName, ctx);
|
|
329
|
-
}
|
|
330
|
-
} catch {
|
|
331
|
-
ctx.data = { message: "Invalid JSON" };
|
|
332
|
-
invokeHandlers(controller, entry.handlers, "error", ctx);
|
|
333
|
-
}
|
|
334
|
-
});
|
|
335
|
-
ws.on("close", () => {
|
|
336
|
-
activeConnections.value--;
|
|
337
|
-
invokeHandlers(controller, entry.handlers, "disconnect", ctx);
|
|
338
|
-
roomManager.leaveAll(socketId);
|
|
339
|
-
entry.sockets.delete(socketId);
|
|
340
|
-
entry.contexts.delete(socketId);
|
|
341
|
-
});
|
|
342
|
-
ws.on("error", (err) => {
|
|
343
|
-
wsErrors.value++;
|
|
344
|
-
ctx.data = {
|
|
345
|
-
message: err.message,
|
|
346
|
-
name: err.name
|
|
347
|
-
};
|
|
348
|
-
invokeHandlers(controller, entry.handlers, "error", ctx);
|
|
349
|
-
});
|
|
350
|
-
};
|
|
351
|
-
return {
|
|
352
|
-
getStats,
|
|
353
|
-
userRoom,
|
|
354
|
-
broadcastToUser,
|
|
355
|
-
totalConnections,
|
|
356
|
-
activeConnections,
|
|
357
|
-
messagesReceived,
|
|
358
|
-
messagesSent,
|
|
359
|
-
wsErrors,
|
|
360
|
-
beforeStart({ container: containerArg }) {
|
|
361
|
-
container = containerArg;
|
|
362
|
-
container.registerInstance(WS_ADAPTER, {
|
|
363
|
-
getStats,
|
|
364
|
-
userRoom,
|
|
365
|
-
broadcastToUser,
|
|
366
|
-
totalConnections,
|
|
367
|
-
activeConnections,
|
|
368
|
-
messagesReceived,
|
|
369
|
-
messagesSent,
|
|
370
|
-
wsErrors
|
|
371
|
-
});
|
|
372
|
-
container.registerInstance(WS_ROOM_MANAGER, roomManager);
|
|
373
|
-
container.registerInstance(WS_USER_BROADCASTER, buildUserBroadcaster());
|
|
374
|
-
for (const controllerClass of wsControllerRegistry) {
|
|
375
|
-
const namespace = getClassMetaOrUndefined(WS_METADATA.WS_CONTROLLER, controllerClass);
|
|
376
|
-
if (namespace === void 0) continue;
|
|
377
|
-
const handlers = getClassMeta(WS_METADATA.WS_HANDLERS, controllerClass, []);
|
|
378
|
-
const fullPath = basePath + (namespace === "/" ? "" : namespace);
|
|
379
|
-
namespaces.set(fullPath, {
|
|
380
|
-
namespace,
|
|
381
|
-
controllerClass,
|
|
382
|
-
handlers,
|
|
383
|
-
sockets: /* @__PURE__ */ new Map(),
|
|
384
|
-
contexts: /* @__PURE__ */ new Map()
|
|
385
|
-
});
|
|
386
|
-
log.info(`Registered WS namespace: ${fullPath} (${controllerClass.name})`);
|
|
387
|
-
}
|
|
388
|
-
},
|
|
389
|
-
afterStart({ server }) {
|
|
390
|
-
if (!server) return;
|
|
391
|
-
wss = new WebSocketServer({
|
|
392
|
-
noServer: true,
|
|
393
|
-
maxPayload
|
|
394
|
-
});
|
|
395
|
-
server.on("upgrade", (request, socket, head) => {
|
|
396
|
-
const pathname = (request.url || "/").split("?")[0];
|
|
397
|
-
const entry = namespaces.get(pathname);
|
|
398
|
-
if (!entry) {
|
|
399
|
-
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
400
|
-
socket.destroy();
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
404
|
-
handleConnection(ws, entry, request);
|
|
405
|
-
});
|
|
406
|
-
});
|
|
407
|
-
if (heartbeatInterval > 0) heartbeatTimer = setInterval(() => {
|
|
408
|
-
for (const [, entry] of namespaces) for (const [, socket] of entry.sockets) {
|
|
409
|
-
if (socket.__alive === false) {
|
|
410
|
-
socket.terminate();
|
|
411
|
-
continue;
|
|
412
|
-
}
|
|
413
|
-
socket.__alive = false;
|
|
414
|
-
socket.ping();
|
|
415
|
-
}
|
|
416
|
-
}, heartbeatInterval);
|
|
417
|
-
const totalHandlers = Array.from(namespaces.values()).reduce((sum, e) => sum + e.handlers.length, 0);
|
|
418
|
-
log.info(`WebSocket ready — ${namespaces.size} namespace(s), ${totalHandlers} handler(s)`);
|
|
419
|
-
},
|
|
420
|
-
shutdown() {
|
|
421
|
-
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
422
|
-
for (const [, entry] of namespaces) {
|
|
423
|
-
for (const [, socket] of entry.sockets) socket.close(1001, "Server shutting down");
|
|
424
|
-
entry.sockets.clear();
|
|
425
|
-
entry.contexts.clear();
|
|
426
|
-
}
|
|
427
|
-
wss?.close();
|
|
428
|
-
}
|
|
429
|
-
};
|
|
430
|
-
}
|
|
431
|
-
});
|
|
432
|
-
//#endregion
|
|
433
|
-
//#region src/decorators.ts
|
|
434
|
-
/**
|
|
435
|
-
* Mark a class as a WebSocket controller with a namespace path.
|
|
436
|
-
* Registers the class in the DI container and the WS controller registry.
|
|
437
|
-
*
|
|
438
|
-
* @example
|
|
439
|
-
* ```ts
|
|
440
|
-
* @WsController('/chat')
|
|
441
|
-
* export class ChatController {
|
|
442
|
-
* @OnConnect()
|
|
443
|
-
* handleConnect(ctx: WsContext) { }
|
|
444
|
-
*
|
|
445
|
-
* @OnMessage('send')
|
|
446
|
-
* handleSend(ctx: WsContext) { }
|
|
447
|
-
* }
|
|
448
|
-
* ```
|
|
449
|
-
*/
|
|
450
|
-
function WsController(namespace) {
|
|
451
|
-
return (target) => {
|
|
452
|
-
Service()(target);
|
|
453
|
-
setClassMeta(WS_METADATA.WS_CONTROLLER, namespace || "/", target);
|
|
454
|
-
wsControllerRegistry.add(target);
|
|
455
|
-
};
|
|
456
|
-
}
|
|
457
|
-
function createWsHandlerDecorator(type, event) {
|
|
458
|
-
return () => {
|
|
459
|
-
return (target, propertyKey) => {
|
|
460
|
-
pushClassMeta(WS_METADATA.WS_HANDLERS, target.constructor, {
|
|
461
|
-
type,
|
|
462
|
-
event,
|
|
463
|
-
handlerName: propertyKey
|
|
464
|
-
});
|
|
465
|
-
};
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
/**
|
|
469
|
-
* Handle new WebSocket connections.
|
|
470
|
-
*
|
|
471
|
-
* @example
|
|
472
|
-
* ```ts
|
|
473
|
-
* @OnConnect()
|
|
474
|
-
* handleConnect(ctx: WsContext) {
|
|
475
|
-
* console.log(`Client ${ctx.id} connected`)
|
|
476
|
-
* }
|
|
477
|
-
* ```
|
|
478
|
-
*/
|
|
479
|
-
const OnConnect = createWsHandlerDecorator("connect");
|
|
480
|
-
/**
|
|
481
|
-
* Handle WebSocket disconnections.
|
|
482
|
-
*
|
|
483
|
-
* @example
|
|
484
|
-
* ```ts
|
|
485
|
-
* @OnDisconnect()
|
|
486
|
-
* handleDisconnect(ctx: WsContext) {
|
|
487
|
-
* console.log(`Client ${ctx.id} disconnected`)
|
|
488
|
-
* }
|
|
489
|
-
* ```
|
|
490
|
-
*/
|
|
491
|
-
const OnDisconnect = createWsHandlerDecorator("disconnect");
|
|
492
|
-
/**
|
|
493
|
-
* Handle WebSocket errors.
|
|
494
|
-
*
|
|
495
|
-
* @example
|
|
496
|
-
* ```ts
|
|
497
|
-
* @OnError()
|
|
498
|
-
* handleError(ctx: WsContext) {
|
|
499
|
-
* console.error('WS error:', ctx.data)
|
|
500
|
-
* }
|
|
501
|
-
* ```
|
|
502
|
-
*/
|
|
503
|
-
const OnError = createWsHandlerDecorator("error");
|
|
504
|
-
/**
|
|
505
|
-
* Handle a specific WebSocket message event.
|
|
506
|
-
* Use '*' as a catch-all for unmatched events.
|
|
507
|
-
*
|
|
508
|
-
* Messages must be JSON: `{ "event": "chat:send", "data": { ... } }`
|
|
509
|
-
*
|
|
510
|
-
* @example
|
|
511
|
-
* ```ts
|
|
512
|
-
* @OnMessage('chat:send')
|
|
513
|
-
* handleChatSend(ctx: WsContext) {
|
|
514
|
-
* ctx.broadcast('chat:receive', ctx.data)
|
|
515
|
-
* }
|
|
516
|
-
*
|
|
517
|
-
* @OnMessage('*')
|
|
518
|
-
* handleUnknown(ctx: WsContext) {
|
|
519
|
-
* ctx.send('error', { message: `Unknown event: ${ctx.event}` })
|
|
520
|
-
* }
|
|
521
|
-
* ```
|
|
522
|
-
*/
|
|
523
|
-
function OnMessage(event) {
|
|
524
|
-
return (target, propertyKey) => {
|
|
525
|
-
pushClassMeta(WS_METADATA.WS_HANDLERS, target.constructor, {
|
|
526
|
-
type: "message",
|
|
527
|
-
event,
|
|
528
|
-
handlerName: propertyKey
|
|
529
|
-
});
|
|
530
|
-
};
|
|
531
|
-
}
|
|
532
|
-
//#endregion
|
|
533
|
-
export { OnConnect, OnDisconnect, OnError, OnMessage, RoomManager, WS_ADAPTER, WS_METADATA, WS_ROOM_MANAGER, WS_USER_BROADCASTER, WsAdapter, WsContext, WsController };
|
|
534
|
-
|
|
11
|
+
import{randomUUID}from"node:crypto";import{WebSocketServer}from"ws";import{Service,createLogger,createToken,defineAdapter,getClassMeta,getClassMetaOrUndefined,pushClassMeta,ref,setClassMeta}from"@forinda/kickjs";const WS_METADATA={WS_CONTROLLER:`kick/ws/controller`,WS_HANDLERS:`kick/ws/handlers`},WS_ADAPTER=createToken(`kick/ws/Adapter`),WS_ROOM_MANAGER=createToken(`kick/ws/RoomManager`),WS_USER_BROADCASTER=createToken(`kick/ws/UserBroadcaster`),wsControllerRegistry=new Set;var WsContext=class{id;data;event;namespace;request;metadata=new Map;constructor(socket,server,roomManager,namespaceSockets,id,namespace,request){this.socket=socket,this.server=server,this.roomManager=roomManager,this.namespaceSockets=namespaceSockets,this.id=id,this.namespace=namespace,this.request=request,this.data=null,this.event=``}get cookies(){let header=this.request.headers.cookie;if(!header)return{};let out={};for(let part of header.split(`;`)){let idx=part.indexOf(`=`);if(idx===-1)continue;let k=part.slice(0,idx).trim(),v=part.slice(idx+1).trim();k&&(out[k]=decodeURIComponent(v))}return out}get(key){return this.metadata.get(key)}set(key,value){this.metadata.set(key,value)}send(event,data){this.socket.readyState===this.socket.OPEN&&this.socket.send(JSON.stringify({event,data}))}broadcast(event,data){let message=JSON.stringify({event,data});for(let[id,socket]of this.namespaceSockets)id!==this.id&&socket.readyState===socket.OPEN&&socket.send(message)}broadcastAll(event,data){let message=JSON.stringify({event,data});for(let[,socket]of this.namespaceSockets)socket.readyState===socket.OPEN&&socket.send(message)}join(room){this.roomManager.join(this.id,this.socket,room)}leave(room){this.roomManager.leave(this.id,room)}rooms(){return this.roomManager.getRooms(this.id)}to(room){return{send:(event,data)=>{this.roomManager.broadcast(room,event,data)}}}},RoomManager=class{socketRooms=new Map;roomSockets=new Map;join(socketId,socket,room){this.socketRooms.has(socketId)||this.socketRooms.set(socketId,new Set),this.socketRooms.get(socketId).add(room),this.roomSockets.has(room)||this.roomSockets.set(room,new Map),this.roomSockets.get(room).set(socketId,socket)}leave(socketId,room){this.socketRooms.get(socketId)?.delete(room),this.roomSockets.get(room)?.delete(socketId),this.roomSockets.get(room)?.size===0&&this.roomSockets.delete(room)}leaveAll(socketId){let rooms=this.socketRooms.get(socketId);if(rooms)for(let room of rooms)this.roomSockets.get(room)?.delete(socketId),this.roomSockets.get(room)?.size===0&&this.roomSockets.delete(room);this.socketRooms.delete(socketId)}getRooms(socketId){return Array.from(this.socketRooms.get(socketId)??[])}getSockets(room){return this.roomSockets.get(room)??new Map}getAllRooms(){let result={};for(let[room,sockets]of this.roomSockets)result[room]=sockets.size;return result}broadcast(room,event,data,excludeId){let sockets=this.roomSockets.get(room);if(!sockets)return;let message=JSON.stringify({event,data});for(let[id,socket]of sockets)id!==excludeId&&socket.readyState===socket.OPEN&&socket.send(message)}};const log=createLogger(`WsAdapter`),WsAdapter=defineAdapter({name:`WsAdapter`,defaults:{path:`/ws`,heartbeatInterval:3e4},build:options=>{let basePath=options.path,heartbeatInterval=options.heartbeatInterval,maxPayload=options.maxPayload,auth=options.auth,userRoomPrefix=options.auth?.userRoomPrefix??`user:`,wss=null,container=null,namespaces=new Map,roomManager=new RoomManager,heartbeatTimer=null,totalConnections=ref(0),activeConnections=ref(0),messagesReceived=ref(0),messagesSent=ref(0),wsErrors=ref(0),userRoom=userId=>userRoomPrefix+userId,broadcastToUser=(userId,event,data)=>{roomManager.broadcast(userRoom(userId),event,data)},buildUserBroadcaster=()=>({roomFor:id=>userRoom(id),broadcastToUser:(id,event,data)=>broadcastToUser(id,event,data),toUser:id=>({send:(event,data)=>broadcastToUser(id,event,data)})}),getStats=()=>{let namespaceStats={};for(let[path,entry]of namespaces)namespaceStats[path]={connections:entry.sockets.size,handlers:entry.handlers.length};return{totalConnections:totalConnections.value,activeConnections:activeConnections.value,messagesReceived:messagesReceived.value,messagesSent:messagesSent.value,errors:wsErrors.value,namespaces:namespaceStats,rooms:roomManager.getAllRooms()}},safeInvoke=(controller,method,ctx)=>{try{let result=controller[method](ctx);result instanceof Promise&&result.catch(err=>{log.error({err},`WS handler error in ${method}`)})}catch(err){log.error({err},`WS handler error in ${method}`)}},invokeHandlers=(controller,handlers,type,ctx)=>{for(let handler of handlers)handler.type===type&&safeInvoke(controller,handler.handlerName,ctx)},authenticate=async ctx=>{if(!auth)return!0;try{let user=await auth.resolveUser(ctx.request);return!user||!user.id?(ctx.socket.close(4401,`Unauthorized`),!1):(ctx.set(`user`,user),ctx.set(`userId`,user.id),auth.autoJoinUserRoom!==!1&&ctx.join(userRoom(user.id)),!0)}catch(err){return log.warn(`WS auth failed`,err),ctx.socket.close(4401,`Unauthorized`),!1}},handleConnection=(ws,entry,request)=>{let socketId=randomUUID();ws.__alive=!0,entry.sockets.set(socketId,ws),totalConnections.value++,activeConnections.value++;let ctx=new WsContext(ws,wss,roomManager,entry.sockets,socketId,entry.namespace,request);entry.contexts.set(socketId,ctx);let controller=container.resolve(entry.controllerClass);ws.on(`pong`,()=>{ws.__alive=!0});let authed=auth===void 0;(auth?authenticate(ctx):Promise.resolve(!0)).then(ok=>{ok&&(authed=!0,invokeHandlers(controller,entry.handlers,`connect`,ctx))}),ws.on(`message`,raw=>{if(authed){messagesReceived.value++;try{let parsed=JSON.parse(raw.toString()),event=parsed.event,data=parsed.data;if(!event||typeof event!=`string`){ctx.send(`error`,{message:`Invalid message format: missing "event" field`});return}ctx.event=event,ctx.data=data;let handler=entry.handlers.find(h=>h.type===`message`&&h.event===event);if(handler)safeInvoke(controller,handler.handlerName,ctx);else{let catchAll=entry.handlers.find(h=>h.type===`message`&&h.event===`*`);catchAll&&safeInvoke(controller,catchAll.handlerName,ctx)}}catch{ctx.data={message:`Invalid JSON`},invokeHandlers(controller,entry.handlers,`error`,ctx)}}}),ws.on(`close`,()=>{activeConnections.value--,invokeHandlers(controller,entry.handlers,`disconnect`,ctx),roomManager.leaveAll(socketId),entry.sockets.delete(socketId),entry.contexts.delete(socketId)}),ws.on(`error`,err=>{wsErrors.value++,ctx.data={message:err.message,name:err.name},invokeHandlers(controller,entry.handlers,`error`,ctx)})};return{getStats,userRoom,broadcastToUser,totalConnections,activeConnections,messagesReceived,messagesSent,wsErrors,beforeStart({container:containerArg}){container=containerArg,container.registerInstance(WS_ADAPTER,{getStats,userRoom,broadcastToUser,totalConnections,activeConnections,messagesReceived,messagesSent,wsErrors}),container.registerInstance(WS_ROOM_MANAGER,roomManager),container.registerInstance(WS_USER_BROADCASTER,buildUserBroadcaster());for(let controllerClass of wsControllerRegistry){let namespace=getClassMetaOrUndefined(WS_METADATA.WS_CONTROLLER,controllerClass);if(namespace===void 0)continue;let handlers=getClassMeta(WS_METADATA.WS_HANDLERS,controllerClass,[]),fullPath=basePath+(namespace===`/`?``:namespace);namespaces.set(fullPath,{namespace,controllerClass,handlers,sockets:new Map,contexts:new Map}),log.info(`Registered WS namespace: ${fullPath} (${controllerClass.name})`)}},afterStart({server}){if(!server)return;wss=new WebSocketServer({noServer:!0,maxPayload}),server.on(`upgrade`,(request,socket,head)=>{let pathname=(request.url||`/`).split(`?`)[0],entry=namespaces.get(pathname);if(!entry){socket.write(`HTTP/1.1 404 Not Found\r
|
|
12
|
+
\r
|
|
13
|
+
`),socket.destroy();return}wss.handleUpgrade(request,socket,head,ws=>{handleConnection(ws,entry,request)})}),heartbeatInterval>0&&(heartbeatTimer=setInterval(()=>{for(let[,entry]of namespaces)for(let[,socket]of entry.sockets){if(socket.__alive===!1){socket.terminate();continue}socket.__alive=!1,socket.ping()}},heartbeatInterval));let totalHandlers=Array.from(namespaces.values()).reduce((sum,e)=>sum+e.handlers.length,0);log.info(`WebSocket ready — ${namespaces.size} namespace(s), ${totalHandlers} handler(s)`)},shutdown(){heartbeatTimer&&clearInterval(heartbeatTimer);for(let[,entry]of namespaces){for(let[,socket]of entry.sockets)socket.close(1001,`Server shutting down`);entry.sockets.clear(),entry.contexts.clear()}wss?.close()}}}});function WsController(namespace){return target=>{Service()(target),setClassMeta(WS_METADATA.WS_CONTROLLER,namespace||`/`,target),wsControllerRegistry.add(target)}}function createWsHandlerDecorator(type,event){return()=>(target,propertyKey)=>{pushClassMeta(WS_METADATA.WS_HANDLERS,target.constructor,{type,event,handlerName:propertyKey})}}const OnConnect=createWsHandlerDecorator(`connect`),OnDisconnect=createWsHandlerDecorator(`disconnect`),OnError=createWsHandlerDecorator(`error`);function OnMessage(event){return(target,propertyKey)=>{pushClassMeta(WS_METADATA.WS_HANDLERS,target.constructor,{type:`message`,event,handlerName:propertyKey})}}export{OnConnect,OnDisconnect,OnError,OnMessage,RoomManager,WS_ADAPTER,WS_METADATA,WS_ROOM_MANAGER,WS_USER_BROADCASTER,WsAdapter,WsContext,WsController};
|
|
535
14
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/interfaces.ts","../src/ws-context.ts","../src/room-manager.ts","../src/ws-adapter.ts","../src/decorators.ts"],"sourcesContent":["import type { IncomingMessage } from 'node:http'\nimport { createToken } from '@forinda/kickjs'\nimport type { RoomManager } from './room-manager'\n\ntype Constructor = new (...args: any[]) => any\n\n// String metadata keys (post-Symbol migration). Slash-delimited under\n// `kick/ws/` for consistency with other framework decorators and to keep\n// Reflect.metadata storage collision-safe.\nexport const WS_METADATA = {\n WS_CONTROLLER: 'kick/ws/controller',\n WS_HANDLERS: 'kick/ws/handlers',\n} as const\n\nexport type WsHandlerType = 'connect' | 'disconnect' | 'message' | 'error'\n\nexport interface WsHandlerDefinition {\n type: WsHandlerType\n /** Event name — only for 'message' type */\n event?: string\n /** Method name on the controller class */\n handlerName: string\n}\n\n/**\n * Resolved principal returned from {@link WsAuthConfig.resolveUser}. Only `id`\n * is required — everything else is free-form metadata stashed on the\n * `WsContext` under `user:<field>` keys for handler access.\n */\nexport interface WsAuthenticatedUser {\n id: string\n [key: string]: unknown\n}\n\nexport interface WsAuthConfig {\n /**\n * Resolve a user from the upgrade request. Called once per socket during\n * handshake, before any `@OnConnect` handler fires. Return `null` or throw\n * to reject the socket with HTTP 401.\n */\n resolveUser: (\n request: IncomingMessage,\n ) => Promise<WsAuthenticatedUser | null> | WsAuthenticatedUser | null\n /**\n * When true, each authenticated socket auto-joins `user:<id>` immediately\n * after `resolveUser` resolves. Pairs with {@link WsUserBroadcaster}.\n */\n autoJoinUserRoom?: boolean\n /**\n * Room name prefix for per-user broadcasting (default: `'user:'`).\n * Must match what `@forinda/kickjs-ws`'s `WsUserBroadcaster` targets.\n */\n userRoomPrefix?: string\n}\n\nexport interface WsAdapterOptions {\n /** Base path for WebSocket upgrade (default: '/ws') */\n path?: string\n /** Heartbeat ping interval in ms (default: 30000). Set to 0 to disable. */\n heartbeatInterval?: number\n /** Maximum message payload size in bytes */\n maxPayload?: number\n /** Optional authenticated-handshake configuration. */\n auth?: WsAuthConfig\n}\n\n/**\n * Per-user broadcasting across all WS namespaces. Registered on the DI\n * container when {@link WsAdapterOptions.auth} is configured, but the\n * underlying room (`user:<id>`) can also be joined manually by any\n * controller — the helper works either way.\n */\nexport interface WsUserBroadcaster {\n /** Send a single event to every socket bound to this user. */\n toUser(userId: string): { send(event: string, data: unknown): void }\n /** Convenience — `toUser(id).send(event, data)` in one call. */\n broadcastToUser(userId: string, event: string, data: unknown): void\n /** Room name for a given user (respects `userRoomPrefix`). */\n roomFor(userId: string): string\n}\n\n/** DI token for the live {@link WsAdapter} instance. */\nexport const WS_ADAPTER = createToken<unknown>('kick/ws/Adapter')\n/** DI token for the shared {@link RoomManager}. */\nexport const WS_ROOM_MANAGER = createToken<RoomManager>('kick/ws/RoomManager')\n/** DI token for the per-user broadcaster helper. */\nexport const WS_USER_BROADCASTER = createToken<WsUserBroadcaster>('kick/ws/UserBroadcaster')\n\n/** Registry of all @WsController classes — populated at decorator time */\nexport const wsControllerRegistry = new Set<Constructor>()\n","import type { IncomingMessage } from 'node:http'\nimport type { WebSocket, WebSocketServer } from 'ws'\nimport type { RoomManager } from './room-manager'\n\n/**\n * Context object passed to WebSocket handler methods.\n * Analogous to RequestContext for HTTP controllers.\n *\n * @example\n * ```ts\n * @OnMessage('chat:send')\n * handleSend(ctx: WsContext) {\n * console.log(ctx.data) // parsed message payload\n * ctx.send('chat:ack', { ok: true })\n * ctx.broadcast('chat:receive', ctx.data)\n * ctx.join('room-1')\n * ctx.to('room-1').send('chat:receive', ctx.data)\n * }\n * ```\n */\nexport class WsContext {\n /** Unique connection ID */\n readonly id: string\n /** Parsed message payload (set for @OnMessage handlers) */\n data: any\n /** Event name from the message envelope (set for @OnMessage handlers) */\n event: string\n /** The namespace this connection belongs to */\n readonly namespace: string\n /** The HTTP upgrade request — available from @OnConnect onward. Use to read\n * cookies, headers, query string, and client IP for authenticated handshakes. */\n readonly request: IncomingMessage\n\n private metadata = new Map<string, any>()\n\n constructor(\n readonly socket: WebSocket,\n readonly server: WebSocketServer,\n private readonly roomManager: RoomManager,\n private readonly namespaceSockets: Map<string, WebSocket>,\n id: string,\n namespace: string,\n request: IncomingMessage,\n ) {\n this.id = id\n this.namespace = namespace\n this.request = request\n this.data = null\n this.event = ''\n }\n\n /** Parsed cookies from the upgrade request (raw `Cookie` header parse). */\n get cookies(): Record<string, string> {\n const header = this.request.headers.cookie\n if (!header) return {}\n const out: Record<string, string> = {}\n for (const part of header.split(';')) {\n const idx = part.indexOf('=')\n if (idx === -1) continue\n const k = part.slice(0, idx).trim()\n const v = part.slice(idx + 1).trim()\n if (k) out[k] = decodeURIComponent(v)\n }\n return out\n }\n\n /** Get a metadata value */\n get<T = any>(key: string): T | undefined {\n return this.metadata.get(key)\n }\n\n /** Set a metadata value (persists for the lifetime of the connection) */\n set(key: string, value: any): void {\n this.metadata.set(key, value)\n }\n\n /** Send a message to this socket */\n send(event: string, data: any): void {\n if (this.socket.readyState === this.socket.OPEN) {\n this.socket.send(JSON.stringify({ event, data }))\n }\n }\n\n /** Send to all sockets in the same namespace except this one */\n broadcast(event: string, data: any): void {\n const message = JSON.stringify({ event, data })\n for (const [id, socket] of this.namespaceSockets) {\n if (id !== this.id && socket.readyState === socket.OPEN) {\n socket.send(message)\n }\n }\n }\n\n /** Send to all sockets in the same namespace including this one */\n broadcastAll(event: string, data: any): void {\n const message = JSON.stringify({ event, data })\n for (const [, socket] of this.namespaceSockets) {\n if (socket.readyState === socket.OPEN) {\n socket.send(message)\n }\n }\n }\n\n /** Join a room */\n join(room: string): void {\n this.roomManager.join(this.id, this.socket, room)\n }\n\n /** Leave a room */\n leave(room: string): void {\n this.roomManager.leave(this.id, room)\n }\n\n /** Get all rooms this socket is in */\n rooms(): string[] {\n return this.roomManager.getRooms(this.id)\n }\n\n /** Send to all sockets in a room */\n to(room: string): { send(event: string, data: any): void } {\n return {\n send: (event: string, data: any) => {\n this.roomManager.broadcast(room, event, data)\n },\n }\n }\n}\n","import type { WebSocket } from 'ws'\n\n/**\n * Manages WebSocket room membership and broadcasting.\n * Standalone from ws/socket.io — can be swapped for socket.io's built-in rooms.\n */\nexport class RoomManager {\n /** socketId → set of room names */\n private socketRooms = new Map<string, Set<string>>()\n /** room name → set of { socketId, socket } */\n private roomSockets = new Map<string, Map<string, WebSocket>>()\n\n join(socketId: string, socket: WebSocket, room: string): void {\n if (!this.socketRooms.has(socketId)) {\n this.socketRooms.set(socketId, new Set())\n }\n this.socketRooms.get(socketId)!.add(room)\n\n if (!this.roomSockets.has(room)) {\n this.roomSockets.set(room, new Map())\n }\n this.roomSockets.get(room)!.set(socketId, socket)\n }\n\n leave(socketId: string, room: string): void {\n this.socketRooms.get(socketId)?.delete(room)\n this.roomSockets.get(room)?.delete(socketId)\n\n // Clean up empty rooms\n if (this.roomSockets.get(room)?.size === 0) {\n this.roomSockets.delete(room)\n }\n }\n\n /** Remove socket from all rooms (called on disconnect) */\n leaveAll(socketId: string): void {\n const rooms = this.socketRooms.get(socketId)\n if (rooms) {\n for (const room of rooms) {\n this.roomSockets.get(room)?.delete(socketId)\n if (this.roomSockets.get(room)?.size === 0) {\n this.roomSockets.delete(room)\n }\n }\n }\n this.socketRooms.delete(socketId)\n }\n\n getRooms(socketId: string): string[] {\n return Array.from(this.socketRooms.get(socketId) ?? [])\n }\n\n getSockets(room: string): Map<string, WebSocket> {\n return this.roomSockets.get(room) ?? new Map()\n }\n\n /** Get all rooms with their member counts */\n getAllRooms(): Record<string, number> {\n const result: Record<string, number> = {}\n for (const [room, sockets] of this.roomSockets) {\n result[room] = sockets.size\n }\n return result\n }\n\n /** Broadcast to all sockets in a room, optionally excluding one */\n broadcast(room: string, event: string, data: any, excludeId?: string): void {\n const sockets = this.roomSockets.get(room)\n if (!sockets) return\n\n const message = JSON.stringify({ event, data })\n for (const [id, socket] of sockets) {\n if (id !== excludeId && socket.readyState === socket.OPEN) {\n socket.send(message)\n }\n }\n }\n}\n","import { randomUUID } from 'node:crypto'\nimport { WebSocketServer, type WebSocket } from 'ws'\nimport type { IncomingMessage } from 'node:http'\nimport type { Duplex } from 'node:stream'\nimport {\n defineAdapter,\n type Container,\n createLogger,\n ref,\n type Ref,\n getClassMetaOrUndefined,\n getClassMeta,\n} from '@forinda/kickjs'\nimport {\n WS_ADAPTER,\n WS_METADATA,\n WS_ROOM_MANAGER,\n WS_USER_BROADCASTER,\n wsControllerRegistry,\n type WsAdapterOptions,\n type WsHandlerDefinition,\n type WsUserBroadcaster,\n} from './interfaces'\nimport { WsContext } from './ws-context'\nimport { RoomManager } from './room-manager'\n\nconst log = createLogger('WsAdapter')\n\ninterface NamespaceEntry {\n namespace: string\n controllerClass: any\n handlers: WsHandlerDefinition[]\n sockets: Map<string, WebSocket>\n contexts: Map<string, WsContext>\n}\n\n/**\n * Public extension methods exposed by a WsAdapter instance — broadcast\n * helpers, namespace stats, and reactive counters that DevTools and\n * other adapters consume directly.\n */\nexport interface WsAdapterExtensions {\n /** Snapshot of WebSocket stats — consumed by DevTools / Swagger ws-server discovery. */\n getStats(): {\n totalConnections: number\n activeConnections: number\n messagesReceived: number\n messagesSent: number\n errors: number\n namespaces: Record<string, { connections: number; handlers: number }>\n rooms: ReturnType<RoomManager['getAllRooms']>\n }\n /** Room name used for per-user broadcasting. */\n userRoom(userId: string): string\n /** Broadcast a single event to every socket in `user:<id>` (across namespaces). */\n broadcastToUser(userId: string, event: string, data: unknown): void\n /** Total WebSocket connections ever opened. */\n readonly totalConnections: Ref<number>\n /** Currently active connections. */\n readonly activeConnections: Ref<number>\n /** Total messages received. */\n readonly messagesReceived: Ref<number>\n /** Total messages sent. */\n readonly messagesSent: Ref<number>\n /** Total errors. */\n readonly wsErrors: Ref<number>\n}\n\n/**\n * WebSocket adapter for KickJS. Attaches to the HTTP server and routes\n * WebSocket connections to @WsController classes based on namespace paths.\n *\n * @example\n * ```ts\n * import { WsAdapter } from '@forinda/kickjs-ws'\n *\n * bootstrap({\n * modules: [ChatModule],\n * adapters: [\n * WsAdapter({ path: '/ws' }),\n * ],\n * })\n * ```\n *\n * Clients connect to: `ws://localhost:3000/ws/chat`\n * Messages are JSON: `{ \"event\": \"send\", \"data\": { \"text\": \"hello\" } }`\n */\nexport const WsAdapter = defineAdapter<WsAdapterOptions, WsAdapterExtensions>({\n name: 'WsAdapter',\n defaults: {\n path: '/ws',\n heartbeatInterval: 30000,\n },\n build: (options) => {\n const basePath = options.path!\n const heartbeatInterval = options.heartbeatInterval!\n const maxPayload = options.maxPayload\n const auth = options.auth\n const userRoomPrefix = options.auth?.userRoomPrefix ?? 'user:'\n\n let wss: WebSocketServer | null = null\n let container: Container | null = null\n const namespaces = new Map<string, NamespaceEntry>()\n const roomManager = new RoomManager()\n let heartbeatTimer: ReturnType<typeof setInterval> | null = null\n\n const totalConnections = ref(0)\n const activeConnections = ref(0)\n const messagesReceived = ref(0)\n const messagesSent = ref(0)\n const wsErrors = ref(0)\n\n const userRoom = (userId: string): string => userRoomPrefix + userId\n\n const broadcastToUser = (userId: string, event: string, data: unknown): void => {\n roomManager.broadcast(userRoom(userId), event, data)\n }\n\n const buildUserBroadcaster = (): WsUserBroadcaster => ({\n roomFor: (id) => userRoom(id),\n broadcastToUser: (id, event, data) => broadcastToUser(id, event, data),\n toUser: (id) => ({\n send: (event, data) => broadcastToUser(id, event, data),\n }),\n })\n\n const getStats = () => {\n const namespaceStats: Record<string, { connections: number; handlers: number }> = {}\n for (const [path, entry] of namespaces) {\n namespaceStats[path] = {\n connections: entry.sockets.size,\n handlers: entry.handlers.length,\n }\n }\n return {\n totalConnections: totalConnections.value,\n activeConnections: activeConnections.value,\n messagesReceived: messagesReceived.value,\n messagesSent: messagesSent.value,\n errors: wsErrors.value,\n namespaces: namespaceStats,\n rooms: roomManager.getAllRooms(),\n }\n }\n\n const safeInvoke = (controller: any, method: string, ctx: WsContext): void => {\n try {\n const result = controller[method](ctx)\n if (result instanceof Promise) {\n result.catch((err: Error) => {\n log.error({ err }, `WS handler error in ${method}`)\n })\n }\n } catch (err) {\n log.error({ err }, `WS handler error in ${method}`)\n }\n }\n\n const invokeHandlers = (\n controller: any,\n handlers: WsHandlerDefinition[],\n type: WsHandlerDefinition['type'],\n ctx: WsContext,\n ): void => {\n for (const handler of handlers) {\n if (handler.type === type) {\n safeInvoke(controller, handler.handlerName, ctx)\n }\n }\n }\n\n /**\n * Runs the configured auth hook against the upgrade request. Stashes the\n * resolved user on the context (as `user`, plus mirrored keys) and, when\n * `autoJoinUserRoom` is enabled, joins the socket to `user:<id>`.\n * Returns `false` (and closes the socket with code 4401) on failure.\n */\n const authenticate = async (ctx: WsContext): Promise<boolean> => {\n if (!auth) return true\n try {\n const user = await auth.resolveUser(ctx.request)\n if (!user || !user.id) {\n ctx.socket.close(4401, 'Unauthorized')\n return false\n }\n ctx.set('user', user)\n ctx.set('userId', user.id)\n if (auth.autoJoinUserRoom !== false) {\n ctx.join(userRoom(user.id))\n }\n return true\n } catch (err) {\n log.warn('WS auth failed', err)\n ctx.socket.close(4401, 'Unauthorized')\n return false\n }\n }\n\n const handleConnection = (\n ws: WebSocket,\n entry: NamespaceEntry,\n request: IncomingMessage,\n ): void => {\n const socketId = randomUUID()\n ;(ws as any).__alive = true\n\n entry.sockets.set(socketId, ws)\n totalConnections.value++\n activeConnections.value++\n\n const ctx = new WsContext(\n ws,\n wss!,\n roomManager,\n entry.sockets,\n socketId,\n entry.namespace,\n request,\n )\n entry.contexts.set(socketId, ctx)\n\n const controller = container!.resolve(entry.controllerClass)\n\n ws.on('pong', () => {\n ;(ws as any).__alive = true\n })\n\n // Authenticated handshake (optional). Messages received before auth\n // resolves are buffered on the socket; we gate dispatch on `authed`.\n let authed = auth === undefined\n const authPromise = auth ? authenticate(ctx) : Promise.resolve(true)\n\n authPromise.then((ok) => {\n if (!ok) return\n authed = true\n invokeHandlers(controller, entry.handlers, 'connect', ctx)\n })\n\n ws.on('message', (raw: Buffer | string) => {\n if (!authed) return\n messagesReceived.value++\n try {\n const parsed = JSON.parse(raw.toString())\n const event = parsed.event as string\n const data = parsed.data\n\n if (!event || typeof event !== 'string') {\n ctx.send('error', { message: 'Invalid message format: missing \"event\" field' })\n return\n }\n\n ctx.event = event\n ctx.data = data\n\n const handler = entry.handlers.find((h) => h.type === 'message' && h.event === event)\n\n if (handler) {\n safeInvoke(controller, handler.handlerName, ctx)\n } else {\n const catchAll = entry.handlers.find((h) => h.type === 'message' && h.event === '*')\n if (catchAll) {\n safeInvoke(controller, catchAll.handlerName, ctx)\n }\n }\n } catch {\n ctx.data = { message: 'Invalid JSON' }\n invokeHandlers(controller, entry.handlers, 'error', ctx)\n }\n })\n\n ws.on('close', () => {\n activeConnections.value--\n invokeHandlers(controller, entry.handlers, 'disconnect', ctx)\n roomManager.leaveAll(socketId)\n entry.sockets.delete(socketId)\n entry.contexts.delete(socketId)\n })\n\n ws.on('error', (err: Error) => {\n wsErrors.value++\n ctx.data = { message: err.message, name: err.name }\n invokeHandlers(controller, entry.handlers, 'error', ctx)\n })\n }\n\n return {\n getStats,\n userRoom,\n broadcastToUser,\n totalConnections,\n activeConnections,\n messagesReceived,\n messagesSent,\n wsErrors,\n\n beforeStart({ container: containerArg }) {\n container = containerArg\n\n // The factory's mutate-name pattern means `this` inside lifecycle\n // hooks does not reach the returned adapter object; pass the\n // adapter itself via WS_ADAPTER for code that wants the full\n // surface (broadcast helpers, stats, refs).\n // We don't have a `this` reference for the WS_ADAPTER token, so\n // we rebuild the externally-visible surface inline here.\n container.registerInstance(WS_ADAPTER, {\n getStats,\n userRoom,\n broadcastToUser,\n totalConnections,\n activeConnections,\n messagesReceived,\n messagesSent,\n wsErrors,\n })\n container.registerInstance(WS_ROOM_MANAGER, roomManager)\n container.registerInstance(WS_USER_BROADCASTER, buildUserBroadcaster())\n\n // Discover all @WsController classes and build routing table\n for (const controllerClass of wsControllerRegistry) {\n const namespace = getClassMetaOrUndefined<string>(\n WS_METADATA.WS_CONTROLLER,\n controllerClass,\n )\n if (namespace === undefined) continue\n\n const handlers = getClassMeta<WsHandlerDefinition[]>(\n WS_METADATA.WS_HANDLERS,\n controllerClass,\n [],\n )\n\n const fullPath = basePath + (namespace === '/' ? '' : namespace)\n\n namespaces.set(fullPath, {\n namespace,\n controllerClass,\n handlers,\n sockets: new Map(),\n contexts: new Map(),\n })\n\n log.info(`Registered WS namespace: ${fullPath} (${controllerClass.name})`)\n }\n },\n\n afterStart({ server }) {\n if (!server) return\n\n wss = new WebSocketServer({\n noServer: true,\n maxPayload,\n })\n\n // Handle upgrade requests — route to correct namespace\n server.on('upgrade', (request: IncomingMessage, socket: Duplex, head: Buffer) => {\n const url = request.url || '/'\n // Parse pathname without relying on host header\n const pathname = url.split('?')[0]\n\n const entry = namespaces.get(pathname)\n if (!entry) {\n socket.write('HTTP/1.1 404 Not Found\\r\\n\\r\\n')\n socket.destroy()\n return\n }\n\n wss!.handleUpgrade(request, socket, head, (ws) => {\n handleConnection(ws, entry, request)\n })\n })\n\n // Heartbeat ping/pong\n if (heartbeatInterval > 0) {\n heartbeatTimer = setInterval(() => {\n for (const [, entry] of namespaces) {\n for (const [, socket] of entry.sockets) {\n if ((socket as any).__alive === false) {\n socket.terminate()\n continue\n }\n ;(socket as any).__alive = false\n socket.ping()\n }\n }\n }, heartbeatInterval)\n }\n\n const totalHandlers = Array.from(namespaces.values()).reduce(\n (sum, e) => sum + e.handlers.length,\n 0,\n )\n log.info(`WebSocket ready — ${namespaces.size} namespace(s), ${totalHandlers} handler(s)`)\n },\n\n shutdown() {\n if (heartbeatTimer) {\n clearInterval(heartbeatTimer)\n }\n\n // Close all connections\n for (const [, entry] of namespaces) {\n for (const [, socket] of entry.sockets) {\n socket.close(1001, 'Server shutting down')\n }\n entry.sockets.clear()\n entry.contexts.clear()\n }\n\n wss?.close()\n },\n }\n },\n})\n","import { Service, setClassMeta, pushClassMeta } from '@forinda/kickjs'\nimport { WS_METADATA, wsControllerRegistry, type WsHandlerDefinition } from './interfaces'\n\n/**\n * Mark a class as a WebSocket controller with a namespace path.\n * Registers the class in the DI container and the WS controller registry.\n *\n * @example\n * ```ts\n * @WsController('/chat')\n * export class ChatController {\n * @OnConnect()\n * handleConnect(ctx: WsContext) { }\n *\n * @OnMessage('send')\n * handleSend(ctx: WsContext) { }\n * }\n * ```\n */\nexport function WsController(namespace?: string): ClassDecorator {\n return (target: any) => {\n Service()(target)\n setClassMeta(WS_METADATA.WS_CONTROLLER, namespace || '/', target)\n wsControllerRegistry.add(target)\n }\n}\n\nfunction createWsHandlerDecorator(type: WsHandlerDefinition['type'], event?: string) {\n return (): MethodDecorator => {\n return (target, propertyKey) => {\n pushClassMeta<WsHandlerDefinition>(WS_METADATA.WS_HANDLERS, target.constructor, {\n type,\n event,\n handlerName: propertyKey as string,\n })\n }\n }\n}\n\n/**\n * Handle new WebSocket connections.\n *\n * @example\n * ```ts\n * @OnConnect()\n * handleConnect(ctx: WsContext) {\n * console.log(`Client ${ctx.id} connected`)\n * }\n * ```\n */\nexport const OnConnect = createWsHandlerDecorator('connect')\n\n/**\n * Handle WebSocket disconnections.\n *\n * @example\n * ```ts\n * @OnDisconnect()\n * handleDisconnect(ctx: WsContext) {\n * console.log(`Client ${ctx.id} disconnected`)\n * }\n * ```\n */\nexport const OnDisconnect = createWsHandlerDecorator('disconnect')\n\n/**\n * Handle WebSocket errors.\n *\n * @example\n * ```ts\n * @OnError()\n * handleError(ctx: WsContext) {\n * console.error('WS error:', ctx.data)\n * }\n * ```\n */\nexport const OnError = createWsHandlerDecorator('error')\n\n/**\n * Handle a specific WebSocket message event.\n * Use '*' as a catch-all for unmatched events.\n *\n * Messages must be JSON: `{ \"event\": \"chat:send\", \"data\": { ... } }`\n *\n * @example\n * ```ts\n * @OnMessage('chat:send')\n * handleChatSend(ctx: WsContext) {\n * ctx.broadcast('chat:receive', ctx.data)\n * }\n *\n * @OnMessage('*')\n * handleUnknown(ctx: WsContext) {\n * ctx.send('error', { message: `Unknown event: ${ctx.event}` })\n * }\n * ```\n */\nexport function OnMessage(event: string): MethodDecorator {\n return (target, propertyKey) => {\n pushClassMeta<WsHandlerDefinition>(WS_METADATA.WS_HANDLERS, target.constructor, {\n type: 'message',\n event,\n handlerName: propertyKey as string,\n })\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/interfaces.ts","../src/ws-context.ts","../src/room-manager.ts","../src/ws-adapter.ts","../src/decorators.ts"],"sourcesContent":["import type { IncomingMessage } from 'node:http'\nimport { createToken } from '@forinda/kickjs'\nimport type { RoomManager } from './room-manager'\n\ntype Constructor = new (...args: any[]) => any\n\n// String metadata keys (post-Symbol migration). Slash-delimited under\n// `kick/ws/` for consistency with other framework decorators and to keep\n// Reflect.metadata storage collision-safe.\nexport const WS_METADATA = {\n WS_CONTROLLER: 'kick/ws/controller',\n WS_HANDLERS: 'kick/ws/handlers',\n} as const\n\nexport type WsHandlerType = 'connect' | 'disconnect' | 'message' | 'error'\n\nexport interface WsHandlerDefinition {\n type: WsHandlerType\n /** Event name — only for 'message' type */\n event?: string\n /** Method name on the controller class */\n handlerName: string\n}\n\n/**\n * Resolved principal returned from {@link WsAuthConfig.resolveUser}. Only `id`\n * is required — everything else is free-form metadata stashed on the\n * `WsContext` under `user:<field>` keys for handler access.\n */\nexport interface WsAuthenticatedUser {\n id: string\n [key: string]: unknown\n}\n\nexport interface WsAuthConfig {\n /**\n * Resolve a user from the upgrade request. Called once per socket during\n * handshake, before any `@OnConnect` handler fires. Return `null` or throw\n * to reject the socket with HTTP 401.\n */\n resolveUser: (\n request: IncomingMessage,\n ) => Promise<WsAuthenticatedUser | null> | WsAuthenticatedUser | null\n /**\n * When true, each authenticated socket auto-joins `user:<id>` immediately\n * after `resolveUser` resolves. Pairs with {@link WsUserBroadcaster}.\n */\n autoJoinUserRoom?: boolean\n /**\n * Room name prefix for per-user broadcasting (default: `'user:'`).\n * Must match what `@forinda/kickjs-ws`'s `WsUserBroadcaster` targets.\n */\n userRoomPrefix?: string\n}\n\nexport interface WsAdapterOptions {\n /** Base path for WebSocket upgrade (default: '/ws') */\n path?: string\n /** Heartbeat ping interval in ms (default: 30000). Set to 0 to disable. */\n heartbeatInterval?: number\n /** Maximum message payload size in bytes */\n maxPayload?: number\n /** Optional authenticated-handshake configuration. */\n auth?: WsAuthConfig\n}\n\n/**\n * Per-user broadcasting across all WS namespaces. Registered on the DI\n * container when {@link WsAdapterOptions.auth} is configured, but the\n * underlying room (`user:<id>`) can also be joined manually by any\n * controller — the helper works either way.\n */\nexport interface WsUserBroadcaster {\n /** Send a single event to every socket bound to this user. */\n toUser(userId: string): { send(event: string, data: unknown): void }\n /** Convenience — `toUser(id).send(event, data)` in one call. */\n broadcastToUser(userId: string, event: string, data: unknown): void\n /** Room name for a given user (respects `userRoomPrefix`). */\n roomFor(userId: string): string\n}\n\n/** DI token for the live {@link WsAdapter} instance. */\nexport const WS_ADAPTER = createToken<unknown>('kick/ws/Adapter')\n/** DI token for the shared {@link RoomManager}. */\nexport const WS_ROOM_MANAGER = createToken<RoomManager>('kick/ws/RoomManager')\n/** DI token for the per-user broadcaster helper. */\nexport const WS_USER_BROADCASTER = createToken<WsUserBroadcaster>('kick/ws/UserBroadcaster')\n\n/** Registry of all @WsController classes — populated at decorator time */\nexport const wsControllerRegistry = new Set<Constructor>()\n","import type { IncomingMessage } from 'node:http'\nimport type { WebSocket, WebSocketServer } from 'ws'\nimport type { RoomManager } from './room-manager'\n\n/**\n * Context object passed to WebSocket handler methods.\n * Analogous to RequestContext for HTTP controllers.\n *\n * @example\n * ```ts\n * @OnMessage('chat:send')\n * handleSend(ctx: WsContext) {\n * console.log(ctx.data) // parsed message payload\n * ctx.send('chat:ack', { ok: true })\n * ctx.broadcast('chat:receive', ctx.data)\n * ctx.join('room-1')\n * ctx.to('room-1').send('chat:receive', ctx.data)\n * }\n * ```\n */\nexport class WsContext {\n /** Unique connection ID */\n readonly id: string\n /** Parsed message payload (set for @OnMessage handlers) */\n data: any\n /** Event name from the message envelope (set for @OnMessage handlers) */\n event: string\n /** The namespace this connection belongs to */\n readonly namespace: string\n /** The HTTP upgrade request — available from @OnConnect onward. Use to read\n * cookies, headers, query string, and client IP for authenticated handshakes. */\n readonly request: IncomingMessage\n\n private metadata = new Map<string, any>()\n\n constructor(\n readonly socket: WebSocket,\n readonly server: WebSocketServer,\n private readonly roomManager: RoomManager,\n private readonly namespaceSockets: Map<string, WebSocket>,\n id: string,\n namespace: string,\n request: IncomingMessage,\n ) {\n this.id = id\n this.namespace = namespace\n this.request = request\n this.data = null\n this.event = ''\n }\n\n /** Parsed cookies from the upgrade request (raw `Cookie` header parse). */\n get cookies(): Record<string, string> {\n const header = this.request.headers.cookie\n if (!header) return {}\n const out: Record<string, string> = {}\n for (const part of header.split(';')) {\n const idx = part.indexOf('=')\n if (idx === -1) continue\n const k = part.slice(0, idx).trim()\n const v = part.slice(idx + 1).trim()\n if (k) out[k] = decodeURIComponent(v)\n }\n return out\n }\n\n /** Get a metadata value */\n get<T = any>(key: string): T | undefined {\n return this.metadata.get(key)\n }\n\n /** Set a metadata value (persists for the lifetime of the connection) */\n set(key: string, value: any): void {\n this.metadata.set(key, value)\n }\n\n /** Send a message to this socket */\n send(event: string, data: any): void {\n if (this.socket.readyState === this.socket.OPEN) {\n this.socket.send(JSON.stringify({ event, data }))\n }\n }\n\n /** Send to all sockets in the same namespace except this one */\n broadcast(event: string, data: any): void {\n const message = JSON.stringify({ event, data })\n for (const [id, socket] of this.namespaceSockets) {\n if (id !== this.id && socket.readyState === socket.OPEN) {\n socket.send(message)\n }\n }\n }\n\n /** Send to all sockets in the same namespace including this one */\n broadcastAll(event: string, data: any): void {\n const message = JSON.stringify({ event, data })\n for (const [, socket] of this.namespaceSockets) {\n if (socket.readyState === socket.OPEN) {\n socket.send(message)\n }\n }\n }\n\n /** Join a room */\n join(room: string): void {\n this.roomManager.join(this.id, this.socket, room)\n }\n\n /** Leave a room */\n leave(room: string): void {\n this.roomManager.leave(this.id, room)\n }\n\n /** Get all rooms this socket is in */\n rooms(): string[] {\n return this.roomManager.getRooms(this.id)\n }\n\n /** Send to all sockets in a room */\n to(room: string): { send(event: string, data: any): void } {\n return {\n send: (event: string, data: any) => {\n this.roomManager.broadcast(room, event, data)\n },\n }\n }\n}\n","import type { WebSocket } from 'ws'\n\n/**\n * Manages WebSocket room membership and broadcasting.\n * Standalone from ws/socket.io — can be swapped for socket.io's built-in rooms.\n */\nexport class RoomManager {\n /** socketId → set of room names */\n private socketRooms = new Map<string, Set<string>>()\n /** room name → set of { socketId, socket } */\n private roomSockets = new Map<string, Map<string, WebSocket>>()\n\n join(socketId: string, socket: WebSocket, room: string): void {\n if (!this.socketRooms.has(socketId)) {\n this.socketRooms.set(socketId, new Set())\n }\n this.socketRooms.get(socketId)!.add(room)\n\n if (!this.roomSockets.has(room)) {\n this.roomSockets.set(room, new Map())\n }\n this.roomSockets.get(room)!.set(socketId, socket)\n }\n\n leave(socketId: string, room: string): void {\n this.socketRooms.get(socketId)?.delete(room)\n this.roomSockets.get(room)?.delete(socketId)\n\n // Clean up empty rooms\n if (this.roomSockets.get(room)?.size === 0) {\n this.roomSockets.delete(room)\n }\n }\n\n /** Remove socket from all rooms (called on disconnect) */\n leaveAll(socketId: string): void {\n const rooms = this.socketRooms.get(socketId)\n if (rooms) {\n for (const room of rooms) {\n this.roomSockets.get(room)?.delete(socketId)\n if (this.roomSockets.get(room)?.size === 0) {\n this.roomSockets.delete(room)\n }\n }\n }\n this.socketRooms.delete(socketId)\n }\n\n getRooms(socketId: string): string[] {\n return Array.from(this.socketRooms.get(socketId) ?? [])\n }\n\n getSockets(room: string): Map<string, WebSocket> {\n return this.roomSockets.get(room) ?? new Map()\n }\n\n /** Get all rooms with their member counts */\n getAllRooms(): Record<string, number> {\n const result: Record<string, number> = {}\n for (const [room, sockets] of this.roomSockets) {\n result[room] = sockets.size\n }\n return result\n }\n\n /** Broadcast to all sockets in a room, optionally excluding one */\n broadcast(room: string, event: string, data: any, excludeId?: string): void {\n const sockets = this.roomSockets.get(room)\n if (!sockets) return\n\n const message = JSON.stringify({ event, data })\n for (const [id, socket] of sockets) {\n if (id !== excludeId && socket.readyState === socket.OPEN) {\n socket.send(message)\n }\n }\n }\n}\n","import { randomUUID } from 'node:crypto'\nimport { WebSocketServer, type WebSocket } from 'ws'\nimport type { IncomingMessage } from 'node:http'\nimport type { Duplex } from 'node:stream'\nimport {\n defineAdapter,\n type Container,\n createLogger,\n ref,\n type Ref,\n getClassMetaOrUndefined,\n getClassMeta,\n} from '@forinda/kickjs'\nimport {\n WS_ADAPTER,\n WS_METADATA,\n WS_ROOM_MANAGER,\n WS_USER_BROADCASTER,\n wsControllerRegistry,\n type WsAdapterOptions,\n type WsHandlerDefinition,\n type WsUserBroadcaster,\n} from './interfaces'\nimport { WsContext } from './ws-context'\nimport { RoomManager } from './room-manager'\n\nconst log = createLogger('WsAdapter')\n\ninterface NamespaceEntry {\n namespace: string\n controllerClass: any\n handlers: WsHandlerDefinition[]\n sockets: Map<string, WebSocket>\n contexts: Map<string, WsContext>\n}\n\n/**\n * Public extension methods exposed by a WsAdapter instance — broadcast\n * helpers, namespace stats, and reactive counters that DevTools and\n * other adapters consume directly.\n */\nexport interface WsAdapterExtensions {\n /** Snapshot of WebSocket stats — consumed by DevTools / Swagger ws-server discovery. */\n getStats(): {\n totalConnections: number\n activeConnections: number\n messagesReceived: number\n messagesSent: number\n errors: number\n namespaces: Record<string, { connections: number; handlers: number }>\n rooms: ReturnType<RoomManager['getAllRooms']>\n }\n /** Room name used for per-user broadcasting. */\n userRoom(userId: string): string\n /** Broadcast a single event to every socket in `user:<id>` (across namespaces). */\n broadcastToUser(userId: string, event: string, data: unknown): void\n /** Total WebSocket connections ever opened. */\n readonly totalConnections: Ref<number>\n /** Currently active connections. */\n readonly activeConnections: Ref<number>\n /** Total messages received. */\n readonly messagesReceived: Ref<number>\n /** Total messages sent. */\n readonly messagesSent: Ref<number>\n /** Total errors. */\n readonly wsErrors: Ref<number>\n}\n\n/**\n * WebSocket adapter for KickJS. Attaches to the HTTP server and routes\n * WebSocket connections to @WsController classes based on namespace paths.\n *\n * @example\n * ```ts\n * import { WsAdapter } from '@forinda/kickjs-ws'\n *\n * bootstrap({\n * modules: [ChatModule],\n * adapters: [\n * WsAdapter({ path: '/ws' }),\n * ],\n * })\n * ```\n *\n * Clients connect to: `ws://localhost:3000/ws/chat`\n * Messages are JSON: `{ \"event\": \"send\", \"data\": { \"text\": \"hello\" } }`\n */\nexport const WsAdapter = defineAdapter<WsAdapterOptions, WsAdapterExtensions>({\n name: 'WsAdapter',\n defaults: {\n path: '/ws',\n heartbeatInterval: 30000,\n },\n build: (options) => {\n const basePath = options.path!\n const heartbeatInterval = options.heartbeatInterval!\n const maxPayload = options.maxPayload\n const auth = options.auth\n const userRoomPrefix = options.auth?.userRoomPrefix ?? 'user:'\n\n let wss: WebSocketServer | null = null\n let container: Container | null = null\n const namespaces = new Map<string, NamespaceEntry>()\n const roomManager = new RoomManager()\n let heartbeatTimer: ReturnType<typeof setInterval> | null = null\n\n const totalConnections = ref(0)\n const activeConnections = ref(0)\n const messagesReceived = ref(0)\n const messagesSent = ref(0)\n const wsErrors = ref(0)\n\n const userRoom = (userId: string): string => userRoomPrefix + userId\n\n const broadcastToUser = (userId: string, event: string, data: unknown): void => {\n roomManager.broadcast(userRoom(userId), event, data)\n }\n\n const buildUserBroadcaster = (): WsUserBroadcaster => ({\n roomFor: (id) => userRoom(id),\n broadcastToUser: (id, event, data) => broadcastToUser(id, event, data),\n toUser: (id) => ({\n send: (event, data) => broadcastToUser(id, event, data),\n }),\n })\n\n const getStats = () => {\n const namespaceStats: Record<string, { connections: number; handlers: number }> = {}\n for (const [path, entry] of namespaces) {\n namespaceStats[path] = {\n connections: entry.sockets.size,\n handlers: entry.handlers.length,\n }\n }\n return {\n totalConnections: totalConnections.value,\n activeConnections: activeConnections.value,\n messagesReceived: messagesReceived.value,\n messagesSent: messagesSent.value,\n errors: wsErrors.value,\n namespaces: namespaceStats,\n rooms: roomManager.getAllRooms(),\n }\n }\n\n const safeInvoke = (controller: any, method: string, ctx: WsContext): void => {\n try {\n const result = controller[method](ctx)\n if (result instanceof Promise) {\n result.catch((err: Error) => {\n log.error({ err }, `WS handler error in ${method}`)\n })\n }\n } catch (err) {\n log.error({ err }, `WS handler error in ${method}`)\n }\n }\n\n const invokeHandlers = (\n controller: any,\n handlers: WsHandlerDefinition[],\n type: WsHandlerDefinition['type'],\n ctx: WsContext,\n ): void => {\n for (const handler of handlers) {\n if (handler.type === type) {\n safeInvoke(controller, handler.handlerName, ctx)\n }\n }\n }\n\n /**\n * Runs the configured auth hook against the upgrade request. Stashes the\n * resolved user on the context (as `user`, plus mirrored keys) and, when\n * `autoJoinUserRoom` is enabled, joins the socket to `user:<id>`.\n * Returns `false` (and closes the socket with code 4401) on failure.\n */\n const authenticate = async (ctx: WsContext): Promise<boolean> => {\n if (!auth) return true\n try {\n const user = await auth.resolveUser(ctx.request)\n if (!user || !user.id) {\n ctx.socket.close(4401, 'Unauthorized')\n return false\n }\n ctx.set('user', user)\n ctx.set('userId', user.id)\n if (auth.autoJoinUserRoom !== false) {\n ctx.join(userRoom(user.id))\n }\n return true\n } catch (err) {\n log.warn('WS auth failed', err)\n ctx.socket.close(4401, 'Unauthorized')\n return false\n }\n }\n\n const handleConnection = (\n ws: WebSocket,\n entry: NamespaceEntry,\n request: IncomingMessage,\n ): void => {\n const socketId = randomUUID()\n ;(ws as any).__alive = true\n\n entry.sockets.set(socketId, ws)\n totalConnections.value++\n activeConnections.value++\n\n const ctx = new WsContext(\n ws,\n wss!,\n roomManager,\n entry.sockets,\n socketId,\n entry.namespace,\n request,\n )\n entry.contexts.set(socketId, ctx)\n\n const controller = container!.resolve(entry.controllerClass)\n\n ws.on('pong', () => {\n ;(ws as any).__alive = true\n })\n\n // Authenticated handshake (optional). Messages received before auth\n // resolves are buffered on the socket; we gate dispatch on `authed`.\n let authed = auth === undefined\n const authPromise = auth ? authenticate(ctx) : Promise.resolve(true)\n\n authPromise.then((ok) => {\n if (!ok) return\n authed = true\n invokeHandlers(controller, entry.handlers, 'connect', ctx)\n })\n\n ws.on('message', (raw: Buffer | string) => {\n if (!authed) return\n messagesReceived.value++\n try {\n const parsed = JSON.parse(raw.toString())\n const event = parsed.event as string\n const data = parsed.data\n\n if (!event || typeof event !== 'string') {\n ctx.send('error', { message: 'Invalid message format: missing \"event\" field' })\n return\n }\n\n ctx.event = event\n ctx.data = data\n\n const handler = entry.handlers.find((h) => h.type === 'message' && h.event === event)\n\n if (handler) {\n safeInvoke(controller, handler.handlerName, ctx)\n } else {\n const catchAll = entry.handlers.find((h) => h.type === 'message' && h.event === '*')\n if (catchAll) {\n safeInvoke(controller, catchAll.handlerName, ctx)\n }\n }\n } catch {\n ctx.data = { message: 'Invalid JSON' }\n invokeHandlers(controller, entry.handlers, 'error', ctx)\n }\n })\n\n ws.on('close', () => {\n activeConnections.value--\n invokeHandlers(controller, entry.handlers, 'disconnect', ctx)\n roomManager.leaveAll(socketId)\n entry.sockets.delete(socketId)\n entry.contexts.delete(socketId)\n })\n\n ws.on('error', (err: Error) => {\n wsErrors.value++\n ctx.data = { message: err.message, name: err.name }\n invokeHandlers(controller, entry.handlers, 'error', ctx)\n })\n }\n\n return {\n getStats,\n userRoom,\n broadcastToUser,\n totalConnections,\n activeConnections,\n messagesReceived,\n messagesSent,\n wsErrors,\n\n beforeStart({ container: containerArg }) {\n container = containerArg\n\n // The factory's mutate-name pattern means `this` inside lifecycle\n // hooks does not reach the returned adapter object; pass the\n // adapter itself via WS_ADAPTER for code that wants the full\n // surface (broadcast helpers, stats, refs).\n // We don't have a `this` reference for the WS_ADAPTER token, so\n // we rebuild the externally-visible surface inline here.\n container.registerInstance(WS_ADAPTER, {\n getStats,\n userRoom,\n broadcastToUser,\n totalConnections,\n activeConnections,\n messagesReceived,\n messagesSent,\n wsErrors,\n })\n container.registerInstance(WS_ROOM_MANAGER, roomManager)\n container.registerInstance(WS_USER_BROADCASTER, buildUserBroadcaster())\n\n // Discover all @WsController classes and build routing table\n for (const controllerClass of wsControllerRegistry) {\n const namespace = getClassMetaOrUndefined<string>(\n WS_METADATA.WS_CONTROLLER,\n controllerClass,\n )\n if (namespace === undefined) continue\n\n const handlers = getClassMeta<WsHandlerDefinition[]>(\n WS_METADATA.WS_HANDLERS,\n controllerClass,\n [],\n )\n\n const fullPath = basePath + (namespace === '/' ? '' : namespace)\n\n namespaces.set(fullPath, {\n namespace,\n controllerClass,\n handlers,\n sockets: new Map(),\n contexts: new Map(),\n })\n\n log.info(`Registered WS namespace: ${fullPath} (${controllerClass.name})`)\n }\n },\n\n afterStart({ server }) {\n if (!server) return\n\n wss = new WebSocketServer({\n noServer: true,\n maxPayload,\n })\n\n // Handle upgrade requests — route to correct namespace\n server.on('upgrade', (request: IncomingMessage, socket: Duplex, head: Buffer) => {\n const url = request.url || '/'\n // Parse pathname without relying on host header\n const pathname = url.split('?')[0]\n\n const entry = namespaces.get(pathname)\n if (!entry) {\n socket.write('HTTP/1.1 404 Not Found\\r\\n\\r\\n')\n socket.destroy()\n return\n }\n\n wss!.handleUpgrade(request, socket, head, (ws) => {\n handleConnection(ws, entry, request)\n })\n })\n\n // Heartbeat ping/pong\n if (heartbeatInterval > 0) {\n heartbeatTimer = setInterval(() => {\n for (const [, entry] of namespaces) {\n for (const [, socket] of entry.sockets) {\n if ((socket as any).__alive === false) {\n socket.terminate()\n continue\n }\n ;(socket as any).__alive = false\n socket.ping()\n }\n }\n }, heartbeatInterval)\n }\n\n const totalHandlers = Array.from(namespaces.values()).reduce(\n (sum, e) => sum + e.handlers.length,\n 0,\n )\n log.info(`WebSocket ready — ${namespaces.size} namespace(s), ${totalHandlers} handler(s)`)\n },\n\n shutdown() {\n if (heartbeatTimer) {\n clearInterval(heartbeatTimer)\n }\n\n // Close all connections\n for (const [, entry] of namespaces) {\n for (const [, socket] of entry.sockets) {\n socket.close(1001, 'Server shutting down')\n }\n entry.sockets.clear()\n entry.contexts.clear()\n }\n\n wss?.close()\n },\n }\n },\n})\n","import { Service, setClassMeta, pushClassMeta } from '@forinda/kickjs'\nimport { WS_METADATA, wsControllerRegistry, type WsHandlerDefinition } from './interfaces'\n\n/**\n * Mark a class as a WebSocket controller with a namespace path.\n * Registers the class in the DI container and the WS controller registry.\n *\n * @example\n * ```ts\n * @WsController('/chat')\n * export class ChatController {\n * @OnConnect()\n * handleConnect(ctx: WsContext) { }\n *\n * @OnMessage('send')\n * handleSend(ctx: WsContext) { }\n * }\n * ```\n */\nexport function WsController(namespace?: string): ClassDecorator {\n return (target: any) => {\n Service()(target)\n setClassMeta(WS_METADATA.WS_CONTROLLER, namespace || '/', target)\n wsControllerRegistry.add(target)\n }\n}\n\nfunction createWsHandlerDecorator(type: WsHandlerDefinition['type'], event?: string) {\n return (): MethodDecorator => {\n return (target, propertyKey) => {\n pushClassMeta<WsHandlerDefinition>(WS_METADATA.WS_HANDLERS, target.constructor, {\n type,\n event,\n handlerName: propertyKey as string,\n })\n }\n }\n}\n\n/**\n * Handle new WebSocket connections.\n *\n * @example\n * ```ts\n * @OnConnect()\n * handleConnect(ctx: WsContext) {\n * console.log(`Client ${ctx.id} connected`)\n * }\n * ```\n */\nexport const OnConnect = createWsHandlerDecorator('connect')\n\n/**\n * Handle WebSocket disconnections.\n *\n * @example\n * ```ts\n * @OnDisconnect()\n * handleDisconnect(ctx: WsContext) {\n * console.log(`Client ${ctx.id} disconnected`)\n * }\n * ```\n */\nexport const OnDisconnect = createWsHandlerDecorator('disconnect')\n\n/**\n * Handle WebSocket errors.\n *\n * @example\n * ```ts\n * @OnError()\n * handleError(ctx: WsContext) {\n * console.error('WS error:', ctx.data)\n * }\n * ```\n */\nexport const OnError = createWsHandlerDecorator('error')\n\n/**\n * Handle a specific WebSocket message event.\n * Use '*' as a catch-all for unmatched events.\n *\n * Messages must be JSON: `{ \"event\": \"chat:send\", \"data\": { ... } }`\n *\n * @example\n * ```ts\n * @OnMessage('chat:send')\n * handleChatSend(ctx: WsContext) {\n * ctx.broadcast('chat:receive', ctx.data)\n * }\n *\n * @OnMessage('*')\n * handleUnknown(ctx: WsContext) {\n * ctx.send('error', { message: `Unknown event: ${ctx.event}` })\n * }\n * ```\n */\nexport function OnMessage(event: string): MethodDecorator {\n return (target, propertyKey) => {\n pushClassMeta<WsHandlerDefinition>(WS_METADATA.WS_HANDLERS, target.constructor, {\n type: 'message',\n event,\n handlerName: propertyKey as string,\n })\n }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forinda/kickjs-ws",
|
|
3
|
-
"version": "5.1
|
|
3
|
+
"version": "5.2.1",
|
|
4
4
|
"description": "WebSocket support with decorators, namespaces, rooms, and DI integration for KickJS",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kickjs",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"@types/ws": "^8.18.0",
|
|
69
69
|
"typescript": "^6.0.3",
|
|
70
70
|
"vitest": "^4.1.5",
|
|
71
|
-
"@forinda/kickjs": "5.
|
|
71
|
+
"@forinda/kickjs": "5.4.0"
|
|
72
72
|
},
|
|
73
73
|
"publishConfig": {
|
|
74
74
|
"access": "public"
|