@forinda/kickjs-ws 2.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +245 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +458 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +28 -12
- package/dist/decorators.d.ts +0 -75
- package/dist/decorators.d.ts.map +0 -1
- package/dist/index.d.ts +0 -6
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -262
- package/dist/interfaces.d.ts +0 -25
- package/dist/interfaces.d.ts.map +0 -1
- package/dist/room-manager.d.ts +0 -22
- package/dist/room-manager.d.ts.map +0 -1
- package/dist/ws-adapter.d.ts +0 -63
- package/dist/ws-adapter.d.ts.map +0 -1
- package/dist/ws-context.d.ts +0 -55
- package/dist/ws-context.d.ts.map +0 -1
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
|
|
2
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
3
|
+
import { AdapterContext, AppAdapter, Ref } from "@forinda/kickjs";
|
|
4
|
+
|
|
5
|
+
//#region src/interfaces.d.ts
|
|
6
|
+
declare const WS_METADATA: {
|
|
7
|
+
readonly WS_CONTROLLER: symbol;
|
|
8
|
+
readonly WS_HANDLERS: symbol;
|
|
9
|
+
};
|
|
10
|
+
type WsHandlerType = 'connect' | 'disconnect' | 'message' | 'error';
|
|
11
|
+
interface WsHandlerDefinition {
|
|
12
|
+
type: WsHandlerType;
|
|
13
|
+
/** Event name — only for 'message' type */
|
|
14
|
+
event?: string;
|
|
15
|
+
/** Method name on the controller class */
|
|
16
|
+
handlerName: string;
|
|
17
|
+
}
|
|
18
|
+
interface WsAdapterOptions {
|
|
19
|
+
/** Base path for WebSocket upgrade (default: '/ws') */
|
|
20
|
+
path?: string;
|
|
21
|
+
/** Heartbeat ping interval in ms (default: 30000). Set to 0 to disable. */
|
|
22
|
+
heartbeatInterval?: number;
|
|
23
|
+
/** Maximum message payload size in bytes */
|
|
24
|
+
maxPayload?: number;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/ws-adapter.d.ts
|
|
28
|
+
/**
|
|
29
|
+
* WebSocket adapter for KickJS. Attaches to the HTTP server and routes
|
|
30
|
+
* WebSocket connections to @WsController classes based on namespace paths.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* import { WsAdapter } from '@forinda/kickjs-ws'
|
|
35
|
+
*
|
|
36
|
+
* bootstrap({
|
|
37
|
+
* modules: [ChatModule],
|
|
38
|
+
* adapters: [
|
|
39
|
+
* new WsAdapter({ path: '/ws' }),
|
|
40
|
+
* ],
|
|
41
|
+
* })
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* Clients connect to: `ws://localhost:3000/ws/chat`
|
|
45
|
+
* Messages are JSON: `{ "event": "send", "data": { "text": "hello" } }`
|
|
46
|
+
*/
|
|
47
|
+
declare class WsAdapter implements AppAdapter {
|
|
48
|
+
readonly name = "WsAdapter";
|
|
49
|
+
private basePath;
|
|
50
|
+
private heartbeatInterval;
|
|
51
|
+
private maxPayload;
|
|
52
|
+
private wss;
|
|
53
|
+
private container;
|
|
54
|
+
private namespaces;
|
|
55
|
+
private roomManager;
|
|
56
|
+
private heartbeatTimer;
|
|
57
|
+
/** Total WebSocket connections ever opened */
|
|
58
|
+
readonly totalConnections: Ref<number>;
|
|
59
|
+
/** Currently active connections */
|
|
60
|
+
readonly activeConnections: Ref<number>;
|
|
61
|
+
/** Total messages received */
|
|
62
|
+
readonly messagesReceived: Ref<number>;
|
|
63
|
+
/** Total messages sent */
|
|
64
|
+
readonly messagesSent: Ref<number>;
|
|
65
|
+
/** Total errors */
|
|
66
|
+
readonly wsErrors: Ref<number>;
|
|
67
|
+
constructor(options?: WsAdapterOptions);
|
|
68
|
+
/** Get a snapshot of WebSocket stats for DevTools */
|
|
69
|
+
getStats(): {
|
|
70
|
+
totalConnections: number;
|
|
71
|
+
activeConnections: number;
|
|
72
|
+
messagesReceived: number;
|
|
73
|
+
messagesSent: number;
|
|
74
|
+
errors: number;
|
|
75
|
+
namespaces: Record<string, {
|
|
76
|
+
connections: number;
|
|
77
|
+
handlers: number;
|
|
78
|
+
}>;
|
|
79
|
+
rooms: Record<string, number>;
|
|
80
|
+
};
|
|
81
|
+
beforeStart({
|
|
82
|
+
container
|
|
83
|
+
}: AdapterContext): void;
|
|
84
|
+
afterStart({
|
|
85
|
+
server
|
|
86
|
+
}: AdapterContext): void;
|
|
87
|
+
shutdown(): void;
|
|
88
|
+
private handleConnection;
|
|
89
|
+
private invokeHandlers;
|
|
90
|
+
private safeInvoke;
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/room-manager.d.ts
|
|
94
|
+
/**
|
|
95
|
+
* Manages WebSocket room membership and broadcasting.
|
|
96
|
+
* Standalone from ws/socket.io — can be swapped for socket.io's built-in rooms.
|
|
97
|
+
*/
|
|
98
|
+
declare class RoomManager {
|
|
99
|
+
/** socketId → set of room names */
|
|
100
|
+
private socketRooms;
|
|
101
|
+
/** room name → set of { socketId, socket } */
|
|
102
|
+
private roomSockets;
|
|
103
|
+
join(socketId: string, socket: WebSocket, room: string): void;
|
|
104
|
+
leave(socketId: string, room: string): void;
|
|
105
|
+
/** Remove socket from all rooms (called on disconnect) */
|
|
106
|
+
leaveAll(socketId: string): void;
|
|
107
|
+
getRooms(socketId: string): string[];
|
|
108
|
+
getSockets(room: string): Map<string, WebSocket>;
|
|
109
|
+
/** Get all rooms with their member counts */
|
|
110
|
+
getAllRooms(): Record<string, number>;
|
|
111
|
+
/** Broadcast to all sockets in a room, optionally excluding one */
|
|
112
|
+
broadcast(room: string, event: string, data: any, excludeId?: string): void;
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/ws-context.d.ts
|
|
116
|
+
/**
|
|
117
|
+
* Context object passed to WebSocket handler methods.
|
|
118
|
+
* Analogous to RequestContext for HTTP controllers.
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```ts
|
|
122
|
+
* @OnMessage('chat:send')
|
|
123
|
+
* handleSend(ctx: WsContext) {
|
|
124
|
+
* console.log(ctx.data) // parsed message payload
|
|
125
|
+
* ctx.send('chat:ack', { ok: true })
|
|
126
|
+
* ctx.broadcast('chat:receive', ctx.data)
|
|
127
|
+
* ctx.join('room-1')
|
|
128
|
+
* ctx.to('room-1').send('chat:receive', ctx.data)
|
|
129
|
+
* }
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
declare class WsContext {
|
|
133
|
+
readonly socket: WebSocket;
|
|
134
|
+
readonly server: WebSocketServer;
|
|
135
|
+
private readonly roomManager;
|
|
136
|
+
private readonly namespaceSockets;
|
|
137
|
+
/** Unique connection ID */
|
|
138
|
+
readonly id: string;
|
|
139
|
+
/** Parsed message payload (set for @OnMessage handlers) */
|
|
140
|
+
data: any;
|
|
141
|
+
/** Event name from the message envelope (set for @OnMessage handlers) */
|
|
142
|
+
event: string;
|
|
143
|
+
/** The namespace this connection belongs to */
|
|
144
|
+
readonly namespace: string;
|
|
145
|
+
private metadata;
|
|
146
|
+
constructor(socket: WebSocket, server: WebSocketServer, roomManager: RoomManager, namespaceSockets: Map<string, WebSocket>, id: string, namespace: string);
|
|
147
|
+
/** Get a metadata value */
|
|
148
|
+
get<T = any>(key: string): T | undefined;
|
|
149
|
+
/** Set a metadata value (persists for the lifetime of the connection) */
|
|
150
|
+
set(key: string, value: any): void;
|
|
151
|
+
/** Send a message to this socket */
|
|
152
|
+
send(event: string, data: any): void;
|
|
153
|
+
/** Send to all sockets in the same namespace except this one */
|
|
154
|
+
broadcast(event: string, data: any): void;
|
|
155
|
+
/** Send to all sockets in the same namespace including this one */
|
|
156
|
+
broadcastAll(event: string, data: any): void;
|
|
157
|
+
/** Join a room */
|
|
158
|
+
join(room: string): void;
|
|
159
|
+
/** Leave a room */
|
|
160
|
+
leave(room: string): void;
|
|
161
|
+
/** Get all rooms this socket is in */
|
|
162
|
+
rooms(): string[];
|
|
163
|
+
/** Send to all sockets in a room */
|
|
164
|
+
to(room: string): {
|
|
165
|
+
send(event: string, data: any): void;
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/decorators.d.ts
|
|
170
|
+
/**
|
|
171
|
+
* Mark a class as a WebSocket controller with a namespace path.
|
|
172
|
+
* Registers the class in the DI container and the WS controller registry.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```ts
|
|
176
|
+
* @WsController('/chat')
|
|
177
|
+
* export class ChatController {
|
|
178
|
+
* @OnConnect()
|
|
179
|
+
* handleConnect(ctx: WsContext) { }
|
|
180
|
+
*
|
|
181
|
+
* @OnMessage('send')
|
|
182
|
+
* handleSend(ctx: WsContext) { }
|
|
183
|
+
* }
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
declare function WsController(namespace?: string): ClassDecorator;
|
|
187
|
+
/**
|
|
188
|
+
* Handle new WebSocket connections.
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```ts
|
|
192
|
+
* @OnConnect()
|
|
193
|
+
* handleConnect(ctx: WsContext) {
|
|
194
|
+
* console.log(`Client ${ctx.id} connected`)
|
|
195
|
+
* }
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
declare const OnConnect: () => MethodDecorator;
|
|
199
|
+
/**
|
|
200
|
+
* Handle WebSocket disconnections.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```ts
|
|
204
|
+
* @OnDisconnect()
|
|
205
|
+
* handleDisconnect(ctx: WsContext) {
|
|
206
|
+
* console.log(`Client ${ctx.id} disconnected`)
|
|
207
|
+
* }
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
declare const OnDisconnect: () => MethodDecorator;
|
|
211
|
+
/**
|
|
212
|
+
* Handle WebSocket errors.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* ```ts
|
|
216
|
+
* @OnError()
|
|
217
|
+
* handleError(ctx: WsContext) {
|
|
218
|
+
* console.error('WS error:', ctx.data)
|
|
219
|
+
* }
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
declare const OnError: () => MethodDecorator;
|
|
223
|
+
/**
|
|
224
|
+
* Handle a specific WebSocket message event.
|
|
225
|
+
* Use '*' as a catch-all for unmatched events.
|
|
226
|
+
*
|
|
227
|
+
* Messages must be JSON: `{ "event": "chat:send", "data": { ... } }`
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* ```ts
|
|
231
|
+
* @OnMessage('chat:send')
|
|
232
|
+
* handleChatSend(ctx: WsContext) {
|
|
233
|
+
* ctx.broadcast('chat:receive', ctx.data)
|
|
234
|
+
* }
|
|
235
|
+
*
|
|
236
|
+
* @OnMessage('*')
|
|
237
|
+
* handleUnknown(ctx: WsContext) {
|
|
238
|
+
* ctx.send('error', { message: `Unknown event: ${ctx.event}` })
|
|
239
|
+
* }
|
|
240
|
+
* ```
|
|
241
|
+
*/
|
|
242
|
+
declare function OnMessage(event: string): MethodDecorator;
|
|
243
|
+
//#endregion
|
|
244
|
+
export { OnConnect, OnDisconnect, OnError, OnMessage, RoomManager, WS_METADATA, WsAdapter, type WsAdapterOptions, WsContext, WsController, type WsHandlerDefinition, type WsHandlerType };
|
|
245
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/interfaces.ts","../src/ws-adapter.ts","../src/room-manager.ts","../src/ws-context.ts","../src/decorators.ts"],"mappings":";;;;;cAEa,WAAA;EAAA,SAGH,aAAA;EAAA,SAAA,WAAA;AAAA;AAAA,KAEE,aAAA;AAAA,UAEK,mBAAA;EACf,IAAA,EAAM,aAAA;;EAEN,KAAA;EALuB;EAOvB,WAAA;AAAA;AAAA,UAGe,gBAAA;EARA;EAUf,IAAA;;EAEA,iBAAA;EAXA;EAaA,UAAA;AAAA;;;;;AArBF;;;;;AAKA;;;;;AAEA;;;;;;;cC2Ca,SAAA,YAAqB,UAAA;EAAA,SACvB,IAAA;EAAA,QAED,QAAA;EAAA,QACA,iBAAA;EAAA,QACA,UAAA;EAAA,QACA,GAAA;EAAA,QACA,SAAA;EAAA,QACA,UAAA;EAAA,QACA,WAAA;EAAA,QACA,cAAA;EDvCE;EAAA,SC2CD,gBAAA,EAAkB,GAAA;;WAElB,iBAAA,EAAmB,GAAA;;WAEnB,gBAAA,EAAkB,GAAA;EAlBN;EAAA,SAoBZ,YAAA,EAAc,GAAA;EANI;EAAA,SAQlB,QAAA,EAAU,GAAA;cAEP,OAAA,GAAS,gBAAA;EAJE;EAiBvB,QAAA,CAAA;;;;;;;;;;;;EAmBA,WAAA,CAAA;IAAc;EAAA,GAAa,cAAA;EA4B3B,UAAA,CAAA;IAAa;EAAA,GAAU,cAAA;EAiDvB,QAAA,CAAA;EAAA,QAmBQ,gBAAA;EAAA,QAgFA,cAAA;EAAA,QAaA,UAAA;AAAA;;;;;;ADvSV;cEIa,WAAA;;UAEH,WAAA;;UAEA,WAAA;EAER,IAAA,CAAK,QAAA,UAAkB,MAAA,EAAQ,SAAA,EAAW,IAAA;EAY1C,KAAA,CAAM,QAAA,UAAkB,IAAA;EFjBD;EE4BvB,QAAA,CAAS,QAAA;EAaT,QAAA,CAAS,QAAA;EAIT,UAAA,CAAW,IAAA,WAAe,GAAA,SAAY,SAAA;;EAKtC,WAAA,CAAA,GAAe,MAAA;EF/Cf;EEwDA,SAAA,CAAU,IAAA,UAAc,KAAA,UAAe,IAAA,OAAW,SAAA;AAAA;;;;;AFhEpD;;;;;AAKA;;;;;AAEA;;;;cGUa,SAAA;EAAA,SAaA,MAAA,EAAQ,SAAA;EAAA,SACR,MAAA,EAAQ,eAAA;EAAA,iBACA,WAAA;EAAA,iBACA,gBAAA;EHrBR;EAAA,SGOF,EAAA;EHJsB;EGM/B,IAAA;EHN+B;EGQ/B,KAAA;EHJA;EAAA,SGMS,SAAA;EAAA,QAED,QAAA;cAGG,MAAA,EAAQ,SAAA,EACR,MAAA,EAAQ,eAAA,EACA,WAAA,EAAa,WAAA,EACb,gBAAA,EAAkB,GAAA,SAAY,SAAA,GAC/C,EAAA,UACA,SAAA;;EASF,GAAA,SAAA,CAAa,GAAA,WAAc,CAAA;;EAK3B,GAAA,CAAI,GAAA,UAAa,KAAA;EFCI;EEIrB,IAAA,CAAK,KAAA,UAAe,IAAA;EFUO;EEH3B,SAAA,CAAU,KAAA,UAAe,IAAA;EFOE;EEG3B,YAAA,CAAa,KAAA,UAAe,IAAA;EFCT;EESnB,IAAA,CAAK,IAAA;;EAKL,KAAA,CAAM,IAAA;EFoBQ;EEfd,KAAA,CAAA;EF2Ca;EEtCb,EAAA,CAAG,IAAA;IAAiB,IAAA,CAAK,KAAA,UAAe,IAAA;EAAA;AAAA;;;;;;;AHhG1C;;;;;AAKA;;;;;AAEA;;iBIUgB,YAAA,CAAa,SAAA,YAAqB,cAAA;;;;;;;;AJFlD;;;;cIiCa,SAAA,QAtBA,eAAA;;;;;;;;AHwBb;;;;cGWa,YAAA,QAnCA,eAAA;;;;;;;;;;;;cAgDA,OAAA,QAhDA,eAAA;;;;;;;;;;;;;;;;;;;;iBAqEG,SAAA,CAAU,KAAA,WAAgB,eAAA"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @forinda/kickjs-ws v2.2.0
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) Felix Orinda
|
|
5
|
+
*
|
|
6
|
+
* This source code is licensed under the MIT license found in the
|
|
7
|
+
* LICENSE file in the root directory of this source tree.
|
|
8
|
+
*
|
|
9
|
+
* @license MIT
|
|
10
|
+
*/
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { WebSocketServer } from "ws";
|
|
13
|
+
import { Service, createLogger, getClassMeta, getClassMetaOrUndefined, pushClassMeta, ref, setClassMeta } from "@forinda/kickjs";
|
|
14
|
+
//#region src/interfaces.ts
|
|
15
|
+
const WS_METADATA = {
|
|
16
|
+
WS_CONTROLLER: Symbol("kick:ws:controller"),
|
|
17
|
+
WS_HANDLERS: Symbol("kick:ws:handlers")
|
|
18
|
+
};
|
|
19
|
+
/** Registry of all @WsController classes — populated at decorator time */
|
|
20
|
+
const wsControllerRegistry = /* @__PURE__ */ new Set();
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region src/ws-context.ts
|
|
23
|
+
/**
|
|
24
|
+
* Context object passed to WebSocket handler methods.
|
|
25
|
+
* Analogous to RequestContext for HTTP controllers.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* @OnMessage('chat:send')
|
|
30
|
+
* handleSend(ctx: WsContext) {
|
|
31
|
+
* console.log(ctx.data) // parsed message payload
|
|
32
|
+
* ctx.send('chat:ack', { ok: true })
|
|
33
|
+
* ctx.broadcast('chat:receive', ctx.data)
|
|
34
|
+
* ctx.join('room-1')
|
|
35
|
+
* ctx.to('room-1').send('chat:receive', ctx.data)
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
var WsContext = class {
|
|
40
|
+
/** Unique connection ID */
|
|
41
|
+
id;
|
|
42
|
+
/** Parsed message payload (set for @OnMessage handlers) */
|
|
43
|
+
data;
|
|
44
|
+
/** Event name from the message envelope (set for @OnMessage handlers) */
|
|
45
|
+
event;
|
|
46
|
+
/** The namespace this connection belongs to */
|
|
47
|
+
namespace;
|
|
48
|
+
metadata = /* @__PURE__ */ new Map();
|
|
49
|
+
constructor(socket, server, roomManager, namespaceSockets, id, namespace) {
|
|
50
|
+
this.socket = socket;
|
|
51
|
+
this.server = server;
|
|
52
|
+
this.roomManager = roomManager;
|
|
53
|
+
this.namespaceSockets = namespaceSockets;
|
|
54
|
+
this.id = id;
|
|
55
|
+
this.namespace = namespace;
|
|
56
|
+
this.data = null;
|
|
57
|
+
this.event = "";
|
|
58
|
+
}
|
|
59
|
+
/** Get a metadata value */
|
|
60
|
+
get(key) {
|
|
61
|
+
return this.metadata.get(key);
|
|
62
|
+
}
|
|
63
|
+
/** Set a metadata value (persists for the lifetime of the connection) */
|
|
64
|
+
set(key, value) {
|
|
65
|
+
this.metadata.set(key, value);
|
|
66
|
+
}
|
|
67
|
+
/** Send a message to this socket */
|
|
68
|
+
send(event, data) {
|
|
69
|
+
if (this.socket.readyState === this.socket.OPEN) this.socket.send(JSON.stringify({
|
|
70
|
+
event,
|
|
71
|
+
data
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
/** Send to all sockets in the same namespace except this one */
|
|
75
|
+
broadcast(event, data) {
|
|
76
|
+
const message = JSON.stringify({
|
|
77
|
+
event,
|
|
78
|
+
data
|
|
79
|
+
});
|
|
80
|
+
for (const [id, socket] of this.namespaceSockets) if (id !== this.id && socket.readyState === socket.OPEN) socket.send(message);
|
|
81
|
+
}
|
|
82
|
+
/** Send to all sockets in the same namespace including this one */
|
|
83
|
+
broadcastAll(event, data) {
|
|
84
|
+
const message = JSON.stringify({
|
|
85
|
+
event,
|
|
86
|
+
data
|
|
87
|
+
});
|
|
88
|
+
for (const [, socket] of this.namespaceSockets) if (socket.readyState === socket.OPEN) socket.send(message);
|
|
89
|
+
}
|
|
90
|
+
/** Join a room */
|
|
91
|
+
join(room) {
|
|
92
|
+
this.roomManager.join(this.id, this.socket, room);
|
|
93
|
+
}
|
|
94
|
+
/** Leave a room */
|
|
95
|
+
leave(room) {
|
|
96
|
+
this.roomManager.leave(this.id, room);
|
|
97
|
+
}
|
|
98
|
+
/** Get all rooms this socket is in */
|
|
99
|
+
rooms() {
|
|
100
|
+
return this.roomManager.getRooms(this.id);
|
|
101
|
+
}
|
|
102
|
+
/** Send to all sockets in a room */
|
|
103
|
+
to(room) {
|
|
104
|
+
return { send: (event, data) => {
|
|
105
|
+
this.roomManager.broadcast(room, event, data);
|
|
106
|
+
} };
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/room-manager.ts
|
|
111
|
+
/**
|
|
112
|
+
* Manages WebSocket room membership and broadcasting.
|
|
113
|
+
* Standalone from ws/socket.io — can be swapped for socket.io's built-in rooms.
|
|
114
|
+
*/
|
|
115
|
+
var RoomManager = class {
|
|
116
|
+
/** socketId → set of room names */
|
|
117
|
+
socketRooms = /* @__PURE__ */ new Map();
|
|
118
|
+
/** room name → set of { socketId, socket } */
|
|
119
|
+
roomSockets = /* @__PURE__ */ new Map();
|
|
120
|
+
join(socketId, socket, room) {
|
|
121
|
+
if (!this.socketRooms.has(socketId)) this.socketRooms.set(socketId, /* @__PURE__ */ new Set());
|
|
122
|
+
this.socketRooms.get(socketId).add(room);
|
|
123
|
+
if (!this.roomSockets.has(room)) this.roomSockets.set(room, /* @__PURE__ */ new Map());
|
|
124
|
+
this.roomSockets.get(room).set(socketId, socket);
|
|
125
|
+
}
|
|
126
|
+
leave(socketId, room) {
|
|
127
|
+
this.socketRooms.get(socketId)?.delete(room);
|
|
128
|
+
this.roomSockets.get(room)?.delete(socketId);
|
|
129
|
+
if (this.roomSockets.get(room)?.size === 0) this.roomSockets.delete(room);
|
|
130
|
+
}
|
|
131
|
+
/** Remove socket from all rooms (called on disconnect) */
|
|
132
|
+
leaveAll(socketId) {
|
|
133
|
+
const rooms = this.socketRooms.get(socketId);
|
|
134
|
+
if (rooms) for (const room of rooms) {
|
|
135
|
+
this.roomSockets.get(room)?.delete(socketId);
|
|
136
|
+
if (this.roomSockets.get(room)?.size === 0) this.roomSockets.delete(room);
|
|
137
|
+
}
|
|
138
|
+
this.socketRooms.delete(socketId);
|
|
139
|
+
}
|
|
140
|
+
getRooms(socketId) {
|
|
141
|
+
return Array.from(this.socketRooms.get(socketId) ?? []);
|
|
142
|
+
}
|
|
143
|
+
getSockets(room) {
|
|
144
|
+
return this.roomSockets.get(room) ?? /* @__PURE__ */ new Map();
|
|
145
|
+
}
|
|
146
|
+
/** Get all rooms with their member counts */
|
|
147
|
+
getAllRooms() {
|
|
148
|
+
const result = {};
|
|
149
|
+
for (const [room, sockets] of this.roomSockets) result[room] = sockets.size;
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
/** Broadcast to all sockets in a room, optionally excluding one */
|
|
153
|
+
broadcast(room, event, data, excludeId) {
|
|
154
|
+
const sockets = this.roomSockets.get(room);
|
|
155
|
+
if (!sockets) return;
|
|
156
|
+
const message = JSON.stringify({
|
|
157
|
+
event,
|
|
158
|
+
data
|
|
159
|
+
});
|
|
160
|
+
for (const [id, socket] of sockets) if (id !== excludeId && socket.readyState === socket.OPEN) socket.send(message);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
//#endregion
|
|
164
|
+
//#region src/ws-adapter.ts
|
|
165
|
+
const log = createLogger("WsAdapter");
|
|
166
|
+
/**
|
|
167
|
+
* WebSocket adapter for KickJS. Attaches to the HTTP server and routes
|
|
168
|
+
* WebSocket connections to @WsController classes based on namespace paths.
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* ```ts
|
|
172
|
+
* import { WsAdapter } from '@forinda/kickjs-ws'
|
|
173
|
+
*
|
|
174
|
+
* bootstrap({
|
|
175
|
+
* modules: [ChatModule],
|
|
176
|
+
* adapters: [
|
|
177
|
+
* new WsAdapter({ path: '/ws' }),
|
|
178
|
+
* ],
|
|
179
|
+
* })
|
|
180
|
+
* ```
|
|
181
|
+
*
|
|
182
|
+
* Clients connect to: `ws://localhost:3000/ws/chat`
|
|
183
|
+
* Messages are JSON: `{ "event": "send", "data": { "text": "hello" } }`
|
|
184
|
+
*/
|
|
185
|
+
var WsAdapter = class {
|
|
186
|
+
name = "WsAdapter";
|
|
187
|
+
basePath;
|
|
188
|
+
heartbeatInterval;
|
|
189
|
+
maxPayload;
|
|
190
|
+
wss = null;
|
|
191
|
+
container = null;
|
|
192
|
+
namespaces = /* @__PURE__ */ new Map();
|
|
193
|
+
roomManager = new RoomManager();
|
|
194
|
+
heartbeatTimer = null;
|
|
195
|
+
/** Total WebSocket connections ever opened */
|
|
196
|
+
totalConnections;
|
|
197
|
+
/** Currently active connections */
|
|
198
|
+
activeConnections;
|
|
199
|
+
/** Total messages received */
|
|
200
|
+
messagesReceived;
|
|
201
|
+
/** Total messages sent */
|
|
202
|
+
messagesSent;
|
|
203
|
+
/** Total errors */
|
|
204
|
+
wsErrors;
|
|
205
|
+
constructor(options = {}) {
|
|
206
|
+
this.basePath = options.path ?? "/ws";
|
|
207
|
+
this.heartbeatInterval = options.heartbeatInterval ?? 3e4;
|
|
208
|
+
this.maxPayload = options.maxPayload;
|
|
209
|
+
this.totalConnections = ref(0);
|
|
210
|
+
this.activeConnections = ref(0);
|
|
211
|
+
this.messagesReceived = ref(0);
|
|
212
|
+
this.messagesSent = ref(0);
|
|
213
|
+
this.wsErrors = ref(0);
|
|
214
|
+
}
|
|
215
|
+
/** Get a snapshot of WebSocket stats for DevTools */
|
|
216
|
+
getStats() {
|
|
217
|
+
const namespaceStats = {};
|
|
218
|
+
for (const [path, entry] of this.namespaces) namespaceStats[path] = {
|
|
219
|
+
connections: entry.sockets.size,
|
|
220
|
+
handlers: entry.handlers.length
|
|
221
|
+
};
|
|
222
|
+
return {
|
|
223
|
+
totalConnections: this.totalConnections.value,
|
|
224
|
+
activeConnections: this.activeConnections.value,
|
|
225
|
+
messagesReceived: this.messagesReceived.value,
|
|
226
|
+
messagesSent: this.messagesSent.value,
|
|
227
|
+
errors: this.wsErrors.value,
|
|
228
|
+
namespaces: namespaceStats,
|
|
229
|
+
rooms: this.roomManager.getAllRooms()
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
beforeStart({ container }) {
|
|
233
|
+
this.container = container;
|
|
234
|
+
for (const controllerClass of wsControllerRegistry) {
|
|
235
|
+
const namespace = getClassMetaOrUndefined(WS_METADATA.WS_CONTROLLER, controllerClass);
|
|
236
|
+
if (namespace === void 0) continue;
|
|
237
|
+
const handlers = getClassMeta(WS_METADATA.WS_HANDLERS, controllerClass, []);
|
|
238
|
+
const fullPath = this.basePath + (namespace === "/" ? "" : namespace);
|
|
239
|
+
this.namespaces.set(fullPath, {
|
|
240
|
+
namespace,
|
|
241
|
+
controllerClass,
|
|
242
|
+
handlers,
|
|
243
|
+
sockets: /* @__PURE__ */ new Map(),
|
|
244
|
+
contexts: /* @__PURE__ */ new Map()
|
|
245
|
+
});
|
|
246
|
+
log.info(`Registered WS namespace: ${fullPath} (${controllerClass.name})`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
afterStart({ server }) {
|
|
250
|
+
if (!server) return;
|
|
251
|
+
this.wss = new WebSocketServer({
|
|
252
|
+
noServer: true,
|
|
253
|
+
maxPayload: this.maxPayload
|
|
254
|
+
});
|
|
255
|
+
server.on("upgrade", (request, socket, head) => {
|
|
256
|
+
const pathname = (request.url || "/").split("?")[0];
|
|
257
|
+
const entry = this.namespaces.get(pathname);
|
|
258
|
+
if (!entry) {
|
|
259
|
+
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
260
|
+
socket.destroy();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
|
264
|
+
this.handleConnection(ws, entry);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
if (this.heartbeatInterval > 0) this.heartbeatTimer = setInterval(() => {
|
|
268
|
+
for (const [, entry] of this.namespaces) for (const [, socket] of entry.sockets) {
|
|
269
|
+
if (socket.__alive === false) {
|
|
270
|
+
socket.terminate();
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
socket.__alive = false;
|
|
274
|
+
socket.ping();
|
|
275
|
+
}
|
|
276
|
+
}, this.heartbeatInterval);
|
|
277
|
+
const totalHandlers = Array.from(this.namespaces.values()).reduce((sum, e) => sum + e.handlers.length, 0);
|
|
278
|
+
log.info(`WebSocket ready — ${this.namespaces.size} namespace(s), ${totalHandlers} handler(s)`);
|
|
279
|
+
}
|
|
280
|
+
shutdown() {
|
|
281
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
282
|
+
for (const [, entry] of this.namespaces) {
|
|
283
|
+
for (const [, socket] of entry.sockets) socket.close(1001, "Server shutting down");
|
|
284
|
+
entry.sockets.clear();
|
|
285
|
+
entry.contexts.clear();
|
|
286
|
+
}
|
|
287
|
+
this.wss?.close();
|
|
288
|
+
}
|
|
289
|
+
handleConnection(ws, entry) {
|
|
290
|
+
const socketId = randomUUID();
|
|
291
|
+
ws.__alive = true;
|
|
292
|
+
entry.sockets.set(socketId, ws);
|
|
293
|
+
this.totalConnections.value++;
|
|
294
|
+
this.activeConnections.value++;
|
|
295
|
+
const ctx = new WsContext(ws, this.wss, this.roomManager, entry.sockets, socketId, entry.namespace);
|
|
296
|
+
entry.contexts.set(socketId, ctx);
|
|
297
|
+
const controller = this.container.resolve(entry.controllerClass);
|
|
298
|
+
ws.on("pong", () => {
|
|
299
|
+
ws.__alive = true;
|
|
300
|
+
});
|
|
301
|
+
this.invokeHandlers(controller, entry.handlers, "connect", ctx);
|
|
302
|
+
ws.on("message", (raw) => {
|
|
303
|
+
this.messagesReceived.value++;
|
|
304
|
+
try {
|
|
305
|
+
const parsed = JSON.parse(raw.toString());
|
|
306
|
+
const event = parsed.event;
|
|
307
|
+
const data = parsed.data;
|
|
308
|
+
if (!event || typeof event !== "string") {
|
|
309
|
+
ctx.send("error", { message: "Invalid message format: missing \"event\" field" });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
ctx.event = event;
|
|
313
|
+
ctx.data = data;
|
|
314
|
+
const handler = entry.handlers.find((h) => h.type === "message" && h.event === event);
|
|
315
|
+
if (handler) this.safeInvoke(controller, handler.handlerName, ctx);
|
|
316
|
+
else {
|
|
317
|
+
const catchAll = entry.handlers.find((h) => h.type === "message" && h.event === "*");
|
|
318
|
+
if (catchAll) this.safeInvoke(controller, catchAll.handlerName, ctx);
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
ctx.data = { message: "Invalid JSON" };
|
|
322
|
+
this.invokeHandlers(controller, entry.handlers, "error", ctx);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
ws.on("close", () => {
|
|
326
|
+
this.activeConnections.value--;
|
|
327
|
+
this.invokeHandlers(controller, entry.handlers, "disconnect", ctx);
|
|
328
|
+
this.roomManager.leaveAll(socketId);
|
|
329
|
+
entry.sockets.delete(socketId);
|
|
330
|
+
entry.contexts.delete(socketId);
|
|
331
|
+
});
|
|
332
|
+
ws.on("error", (err) => {
|
|
333
|
+
this.wsErrors.value++;
|
|
334
|
+
ctx.data = {
|
|
335
|
+
message: err.message,
|
|
336
|
+
name: err.name
|
|
337
|
+
};
|
|
338
|
+
this.invokeHandlers(controller, entry.handlers, "error", ctx);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
invokeHandlers(controller, handlers, type, ctx) {
|
|
342
|
+
for (const handler of handlers) if (handler.type === type) this.safeInvoke(controller, handler.handlerName, ctx);
|
|
343
|
+
}
|
|
344
|
+
safeInvoke(controller, method, ctx) {
|
|
345
|
+
try {
|
|
346
|
+
const result = controller[method](ctx);
|
|
347
|
+
if (result instanceof Promise) result.catch((err) => {
|
|
348
|
+
log.error({ err }, `WS handler error in ${method}`);
|
|
349
|
+
});
|
|
350
|
+
} catch (err) {
|
|
351
|
+
log.error({ err }, `WS handler error in ${method}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/decorators.ts
|
|
357
|
+
/**
|
|
358
|
+
* Mark a class as a WebSocket controller with a namespace path.
|
|
359
|
+
* Registers the class in the DI container and the WS controller registry.
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* ```ts
|
|
363
|
+
* @WsController('/chat')
|
|
364
|
+
* export class ChatController {
|
|
365
|
+
* @OnConnect()
|
|
366
|
+
* handleConnect(ctx: WsContext) { }
|
|
367
|
+
*
|
|
368
|
+
* @OnMessage('send')
|
|
369
|
+
* handleSend(ctx: WsContext) { }
|
|
370
|
+
* }
|
|
371
|
+
* ```
|
|
372
|
+
*/
|
|
373
|
+
function WsController(namespace) {
|
|
374
|
+
return (target) => {
|
|
375
|
+
Service()(target);
|
|
376
|
+
setClassMeta(WS_METADATA.WS_CONTROLLER, namespace || "/", target);
|
|
377
|
+
wsControllerRegistry.add(target);
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function createWsHandlerDecorator(type, event) {
|
|
381
|
+
return () => {
|
|
382
|
+
return (target, propertyKey) => {
|
|
383
|
+
pushClassMeta(WS_METADATA.WS_HANDLERS, target.constructor, {
|
|
384
|
+
type,
|
|
385
|
+
event,
|
|
386
|
+
handlerName: propertyKey
|
|
387
|
+
});
|
|
388
|
+
};
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Handle new WebSocket connections.
|
|
393
|
+
*
|
|
394
|
+
* @example
|
|
395
|
+
* ```ts
|
|
396
|
+
* @OnConnect()
|
|
397
|
+
* handleConnect(ctx: WsContext) {
|
|
398
|
+
* console.log(`Client ${ctx.id} connected`)
|
|
399
|
+
* }
|
|
400
|
+
* ```
|
|
401
|
+
*/
|
|
402
|
+
const OnConnect = createWsHandlerDecorator("connect");
|
|
403
|
+
/**
|
|
404
|
+
* Handle WebSocket disconnections.
|
|
405
|
+
*
|
|
406
|
+
* @example
|
|
407
|
+
* ```ts
|
|
408
|
+
* @OnDisconnect()
|
|
409
|
+
* handleDisconnect(ctx: WsContext) {
|
|
410
|
+
* console.log(`Client ${ctx.id} disconnected`)
|
|
411
|
+
* }
|
|
412
|
+
* ```
|
|
413
|
+
*/
|
|
414
|
+
const OnDisconnect = createWsHandlerDecorator("disconnect");
|
|
415
|
+
/**
|
|
416
|
+
* Handle WebSocket errors.
|
|
417
|
+
*
|
|
418
|
+
* @example
|
|
419
|
+
* ```ts
|
|
420
|
+
* @OnError()
|
|
421
|
+
* handleError(ctx: WsContext) {
|
|
422
|
+
* console.error('WS error:', ctx.data)
|
|
423
|
+
* }
|
|
424
|
+
* ```
|
|
425
|
+
*/
|
|
426
|
+
const OnError = createWsHandlerDecorator("error");
|
|
427
|
+
/**
|
|
428
|
+
* Handle a specific WebSocket message event.
|
|
429
|
+
* Use '*' as a catch-all for unmatched events.
|
|
430
|
+
*
|
|
431
|
+
* Messages must be JSON: `{ "event": "chat:send", "data": { ... } }`
|
|
432
|
+
*
|
|
433
|
+
* @example
|
|
434
|
+
* ```ts
|
|
435
|
+
* @OnMessage('chat:send')
|
|
436
|
+
* handleChatSend(ctx: WsContext) {
|
|
437
|
+
* ctx.broadcast('chat:receive', ctx.data)
|
|
438
|
+
* }
|
|
439
|
+
*
|
|
440
|
+
* @OnMessage('*')
|
|
441
|
+
* handleUnknown(ctx: WsContext) {
|
|
442
|
+
* ctx.send('error', { message: `Unknown event: ${ctx.event}` })
|
|
443
|
+
* }
|
|
444
|
+
* ```
|
|
445
|
+
*/
|
|
446
|
+
function OnMessage(event) {
|
|
447
|
+
return (target, propertyKey) => {
|
|
448
|
+
pushClassMeta(WS_METADATA.WS_HANDLERS, target.constructor, {
|
|
449
|
+
type: "message",
|
|
450
|
+
event,
|
|
451
|
+
handlerName: propertyKey
|
|
452
|
+
});
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
//#endregion
|
|
456
|
+
export { OnConnect, OnDisconnect, OnError, OnMessage, RoomManager, WS_METADATA, WsAdapter, WsContext, WsController };
|
|
457
|
+
|
|
458
|
+
//# sourceMappingURL=index.mjs.map
|