@forinda/kickjs-ws 0.5.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Felix Orinda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,234 @@
1
+ import { AppAdapter, Ref, Container } from '@forinda/kickjs-core';
2
+ import { WebSocket, WebSocketServer } from 'ws';
3
+
4
+ declare const WS_METADATA: {
5
+ readonly WS_CONTROLLER: symbol;
6
+ readonly WS_HANDLERS: symbol;
7
+ };
8
+ type WsHandlerType = 'connect' | 'disconnect' | 'message' | 'error';
9
+ interface WsHandlerDefinition {
10
+ type: WsHandlerType;
11
+ /** Event name — only for 'message' type */
12
+ event?: string;
13
+ /** Method name on the controller class */
14
+ handlerName: string;
15
+ }
16
+ interface WsAdapterOptions {
17
+ /** Base path for WebSocket upgrade (default: '/ws') */
18
+ path?: string;
19
+ /** Heartbeat ping interval in ms (default: 30000). Set to 0 to disable. */
20
+ heartbeatInterval?: number;
21
+ /** Maximum message payload size in bytes */
22
+ maxPayload?: number;
23
+ }
24
+
25
+ /**
26
+ * WebSocket adapter for KickJS. Attaches to the HTTP server and routes
27
+ * WebSocket connections to @WsController classes based on namespace paths.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * import { WsAdapter } from '@forinda/kickjs-ws'
32
+ *
33
+ * bootstrap({
34
+ * modules: [ChatModule],
35
+ * adapters: [
36
+ * new WsAdapter({ path: '/ws' }),
37
+ * ],
38
+ * })
39
+ * ```
40
+ *
41
+ * Clients connect to: `ws://localhost:3000/ws/chat`
42
+ * Messages are JSON: `{ "event": "send", "data": { "text": "hello" } }`
43
+ */
44
+ declare class WsAdapter implements AppAdapter {
45
+ readonly name = "WsAdapter";
46
+ private basePath;
47
+ private heartbeatInterval;
48
+ private maxPayload;
49
+ private wss;
50
+ private container;
51
+ private namespaces;
52
+ private roomManager;
53
+ private heartbeatTimer;
54
+ /** Total WebSocket connections ever opened */
55
+ readonly totalConnections: Ref<number>;
56
+ /** Currently active connections */
57
+ readonly activeConnections: Ref<number>;
58
+ /** Total messages received */
59
+ readonly messagesReceived: Ref<number>;
60
+ /** Total messages sent */
61
+ readonly messagesSent: Ref<number>;
62
+ /** Total errors */
63
+ readonly wsErrors: Ref<number>;
64
+ constructor(options?: WsAdapterOptions);
65
+ /** Get a snapshot of WebSocket stats for DevTools */
66
+ getStats(): {
67
+ totalConnections: number;
68
+ activeConnections: number;
69
+ messagesReceived: number;
70
+ messagesSent: number;
71
+ errors: number;
72
+ namespaces: Record<string, {
73
+ connections: number;
74
+ handlers: number;
75
+ }>;
76
+ rooms: Record<string, number>;
77
+ };
78
+ beforeStart(_app: any, container: Container): void;
79
+ afterStart(server: any, _container: Container): void;
80
+ shutdown(): void;
81
+ private handleConnection;
82
+ private invokeHandlers;
83
+ private safeInvoke;
84
+ }
85
+
86
+ /**
87
+ * Manages WebSocket room membership and broadcasting.
88
+ * Standalone from ws/socket.io — can be swapped for socket.io's built-in rooms.
89
+ */
90
+ declare class RoomManager {
91
+ /** socketId → set of room names */
92
+ private socketRooms;
93
+ /** room name → set of { socketId, socket } */
94
+ private roomSockets;
95
+ join(socketId: string, socket: WebSocket, room: string): void;
96
+ leave(socketId: string, room: string): void;
97
+ /** Remove socket from all rooms (called on disconnect) */
98
+ leaveAll(socketId: string): void;
99
+ getRooms(socketId: string): string[];
100
+ getSockets(room: string): Map<string, WebSocket>;
101
+ /** Get all rooms with their member counts */
102
+ getAllRooms(): Record<string, number>;
103
+ /** Broadcast to all sockets in a room, optionally excluding one */
104
+ broadcast(room: string, event: string, data: any, excludeId?: string): void;
105
+ }
106
+
107
+ /**
108
+ * Context object passed to WebSocket handler methods.
109
+ * Analogous to RequestContext for HTTP controllers.
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * @OnMessage('chat:send')
114
+ * handleSend(ctx: WsContext) {
115
+ * console.log(ctx.data) // parsed message payload
116
+ * ctx.send('chat:ack', { ok: true })
117
+ * ctx.broadcast('chat:receive', ctx.data)
118
+ * ctx.join('room-1')
119
+ * ctx.to('room-1').send('chat:receive', ctx.data)
120
+ * }
121
+ * ```
122
+ */
123
+ declare class WsContext {
124
+ readonly socket: WebSocket;
125
+ readonly server: WebSocketServer;
126
+ private readonly roomManager;
127
+ private readonly namespaceSockets;
128
+ /** Unique connection ID */
129
+ readonly id: string;
130
+ /** Parsed message payload (set for @OnMessage handlers) */
131
+ data: any;
132
+ /** Event name from the message envelope (set for @OnMessage handlers) */
133
+ event: string;
134
+ /** The namespace this connection belongs to */
135
+ readonly namespace: string;
136
+ private metadata;
137
+ constructor(socket: WebSocket, server: WebSocketServer, roomManager: RoomManager, namespaceSockets: Map<string, WebSocket>, id: string, namespace: string);
138
+ /** Get a metadata value */
139
+ get<T = any>(key: string): T | undefined;
140
+ /** Set a metadata value (persists for the lifetime of the connection) */
141
+ set(key: string, value: any): void;
142
+ /** Send a message to this socket */
143
+ send(event: string, data: any): void;
144
+ /** Send to all sockets in the same namespace except this one */
145
+ broadcast(event: string, data: any): void;
146
+ /** Send to all sockets in the same namespace including this one */
147
+ broadcastAll(event: string, data: any): void;
148
+ /** Join a room */
149
+ join(room: string): void;
150
+ /** Leave a room */
151
+ leave(room: string): void;
152
+ /** Get all rooms this socket is in */
153
+ rooms(): string[];
154
+ /** Send to all sockets in a room */
155
+ to(room: string): {
156
+ send(event: string, data: any): void;
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Mark a class as a WebSocket controller with a namespace path.
162
+ * Registers the class in the DI container and the WS controller registry.
163
+ *
164
+ * @example
165
+ * ```ts
166
+ * @WsController('/chat')
167
+ * export class ChatController {
168
+ * @OnConnect()
169
+ * handleConnect(ctx: WsContext) { }
170
+ *
171
+ * @OnMessage('send')
172
+ * handleSend(ctx: WsContext) { }
173
+ * }
174
+ * ```
175
+ */
176
+ declare function WsController(namespace?: string): ClassDecorator;
177
+ /**
178
+ * Handle new WebSocket connections.
179
+ *
180
+ * @example
181
+ * ```ts
182
+ * @OnConnect()
183
+ * handleConnect(ctx: WsContext) {
184
+ * console.log(`Client ${ctx.id} connected`)
185
+ * }
186
+ * ```
187
+ */
188
+ declare const OnConnect: () => MethodDecorator;
189
+ /**
190
+ * Handle WebSocket disconnections.
191
+ *
192
+ * @example
193
+ * ```ts
194
+ * @OnDisconnect()
195
+ * handleDisconnect(ctx: WsContext) {
196
+ * console.log(`Client ${ctx.id} disconnected`)
197
+ * }
198
+ * ```
199
+ */
200
+ declare const OnDisconnect: () => MethodDecorator;
201
+ /**
202
+ * Handle WebSocket errors.
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * @OnError()
207
+ * handleError(ctx: WsContext) {
208
+ * console.error('WS error:', ctx.data)
209
+ * }
210
+ * ```
211
+ */
212
+ declare const OnError: () => MethodDecorator;
213
+ /**
214
+ * Handle a specific WebSocket message event.
215
+ * Use '*' as a catch-all for unmatched events.
216
+ *
217
+ * Messages must be JSON: `{ "event": "chat:send", "data": { ... } }`
218
+ *
219
+ * @example
220
+ * ```ts
221
+ * @OnMessage('chat:send')
222
+ * handleChatSend(ctx: WsContext) {
223
+ * ctx.broadcast('chat:receive', ctx.data)
224
+ * }
225
+ *
226
+ * @OnMessage('*')
227
+ * handleUnknown(ctx: WsContext) {
228
+ * ctx.send('error', { message: `Unknown event: ${ctx.event}` })
229
+ * }
230
+ * ```
231
+ */
232
+ declare function OnMessage(event: string): MethodDecorator;
233
+
234
+ export { OnConnect, OnDisconnect, OnError, OnMessage, RoomManager, WS_METADATA, WsAdapter, type WsAdapterOptions, WsContext, WsController, type WsHandlerDefinition, type WsHandlerType };
package/dist/index.js ADDED
@@ -0,0 +1,434 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/ws-adapter.ts
5
+ import { randomUUID } from "crypto";
6
+ import { WebSocketServer } from "ws";
7
+ import { createLogger, ref } from "@forinda/kickjs-core";
8
+
9
+ // src/interfaces.ts
10
+ var WS_METADATA = {
11
+ WS_CONTROLLER: /* @__PURE__ */ Symbol("kick:ws:controller"),
12
+ WS_HANDLERS: /* @__PURE__ */ Symbol("kick:ws:handlers")
13
+ };
14
+ var wsControllerRegistry = /* @__PURE__ */ new Set();
15
+
16
+ // src/ws-context.ts
17
+ var WsContext = class {
18
+ static {
19
+ __name(this, "WsContext");
20
+ }
21
+ socket;
22
+ server;
23
+ roomManager;
24
+ namespaceSockets;
25
+ /** Unique connection ID */
26
+ id;
27
+ /** Parsed message payload (set for @OnMessage handlers) */
28
+ data;
29
+ /** Event name from the message envelope (set for @OnMessage handlers) */
30
+ event;
31
+ /** The namespace this connection belongs to */
32
+ namespace;
33
+ metadata = /* @__PURE__ */ new Map();
34
+ constructor(socket, server, roomManager, namespaceSockets, id, namespace) {
35
+ this.socket = socket;
36
+ this.server = server;
37
+ this.roomManager = roomManager;
38
+ this.namespaceSockets = namespaceSockets;
39
+ this.id = id;
40
+ this.namespace = namespace;
41
+ this.data = null;
42
+ this.event = "";
43
+ }
44
+ /** Get a metadata value */
45
+ get(key) {
46
+ return this.metadata.get(key);
47
+ }
48
+ /** Set a metadata value (persists for the lifetime of the connection) */
49
+ set(key, value) {
50
+ this.metadata.set(key, value);
51
+ }
52
+ /** Send a message to this socket */
53
+ send(event, data) {
54
+ if (this.socket.readyState === this.socket.OPEN) {
55
+ this.socket.send(JSON.stringify({
56
+ event,
57
+ data
58
+ }));
59
+ }
60
+ }
61
+ /** Send to all sockets in the same namespace except this one */
62
+ broadcast(event, data) {
63
+ const message = JSON.stringify({
64
+ event,
65
+ data
66
+ });
67
+ for (const [id, socket] of this.namespaceSockets) {
68
+ if (id !== this.id && socket.readyState === socket.OPEN) {
69
+ socket.send(message);
70
+ }
71
+ }
72
+ }
73
+ /** Send to all sockets in the same namespace including this one */
74
+ broadcastAll(event, data) {
75
+ const message = JSON.stringify({
76
+ event,
77
+ data
78
+ });
79
+ for (const [, socket] of this.namespaceSockets) {
80
+ if (socket.readyState === socket.OPEN) {
81
+ socket.send(message);
82
+ }
83
+ }
84
+ }
85
+ /** Join a room */
86
+ join(room) {
87
+ this.roomManager.join(this.id, this.socket, room);
88
+ }
89
+ /** Leave a room */
90
+ leave(room) {
91
+ this.roomManager.leave(this.id, room);
92
+ }
93
+ /** Get all rooms this socket is in */
94
+ rooms() {
95
+ return this.roomManager.getRooms(this.id);
96
+ }
97
+ /** Send to all sockets in a room */
98
+ to(room) {
99
+ return {
100
+ send: /* @__PURE__ */ __name((event, data) => {
101
+ this.roomManager.broadcast(room, event, data);
102
+ }, "send")
103
+ };
104
+ }
105
+ };
106
+
107
+ // src/room-manager.ts
108
+ var RoomManager = class {
109
+ static {
110
+ __name(this, "RoomManager");
111
+ }
112
+ /** socketId → set of room names */
113
+ socketRooms = /* @__PURE__ */ new Map();
114
+ /** room name → set of { socketId, socket } */
115
+ roomSockets = /* @__PURE__ */ new Map();
116
+ join(socketId, socket, room) {
117
+ if (!this.socketRooms.has(socketId)) {
118
+ this.socketRooms.set(socketId, /* @__PURE__ */ new Set());
119
+ }
120
+ this.socketRooms.get(socketId).add(room);
121
+ if (!this.roomSockets.has(room)) {
122
+ this.roomSockets.set(room, /* @__PURE__ */ new Map());
123
+ }
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) {
130
+ this.roomSockets.delete(room);
131
+ }
132
+ }
133
+ /** Remove socket from all rooms (called on disconnect) */
134
+ leaveAll(socketId) {
135
+ const rooms = this.socketRooms.get(socketId);
136
+ if (rooms) {
137
+ for (const room of rooms) {
138
+ this.roomSockets.get(room)?.delete(socketId);
139
+ if (this.roomSockets.get(room)?.size === 0) {
140
+ this.roomSockets.delete(room);
141
+ }
142
+ }
143
+ }
144
+ this.socketRooms.delete(socketId);
145
+ }
146
+ getRooms(socketId) {
147
+ return Array.from(this.socketRooms.get(socketId) ?? []);
148
+ }
149
+ getSockets(room) {
150
+ return this.roomSockets.get(room) ?? /* @__PURE__ */ new Map();
151
+ }
152
+ /** Get all rooms with their member counts */
153
+ getAllRooms() {
154
+ const result = {};
155
+ for (const [room, sockets] of this.roomSockets) {
156
+ result[room] = sockets.size;
157
+ }
158
+ return result;
159
+ }
160
+ /** Broadcast to all sockets in a room, optionally excluding one */
161
+ broadcast(room, event, data, excludeId) {
162
+ const sockets = this.roomSockets.get(room);
163
+ if (!sockets) return;
164
+ const message = JSON.stringify({
165
+ event,
166
+ data
167
+ });
168
+ for (const [id, socket] of sockets) {
169
+ if (id !== excludeId && socket.readyState === socket.OPEN) {
170
+ socket.send(message);
171
+ }
172
+ }
173
+ }
174
+ };
175
+
176
+ // src/ws-adapter.ts
177
+ var log = createLogger("WsAdapter");
178
+ var WsAdapter = class {
179
+ static {
180
+ __name(this, "WsAdapter");
181
+ }
182
+ name = "WsAdapter";
183
+ basePath;
184
+ heartbeatInterval;
185
+ maxPayload;
186
+ wss = null;
187
+ container = null;
188
+ namespaces = /* @__PURE__ */ new Map();
189
+ roomManager = new RoomManager();
190
+ heartbeatTimer = null;
191
+ // ── Reactive Stats (exposed for DevToolsAdapter) ─────────────────────
192
+ /** Total WebSocket connections ever opened */
193
+ totalConnections;
194
+ /** Currently active connections */
195
+ activeConnections;
196
+ /** Total messages received */
197
+ messagesReceived;
198
+ /** Total messages sent */
199
+ messagesSent;
200
+ /** Total errors */
201
+ wsErrors;
202
+ constructor(options = {}) {
203
+ this.basePath = options.path ?? "/ws";
204
+ this.heartbeatInterval = options.heartbeatInterval ?? 3e4;
205
+ this.maxPayload = options.maxPayload;
206
+ this.totalConnections = ref(0);
207
+ this.activeConnections = ref(0);
208
+ this.messagesReceived = ref(0);
209
+ this.messagesSent = ref(0);
210
+ this.wsErrors = ref(0);
211
+ }
212
+ /** Get a snapshot of WebSocket stats for DevTools */
213
+ getStats() {
214
+ const namespaceStats = {};
215
+ for (const [path, entry] of this.namespaces) {
216
+ namespaceStats[path] = {
217
+ connections: entry.sockets.size,
218
+ handlers: entry.handlers.length
219
+ };
220
+ }
221
+ return {
222
+ totalConnections: this.totalConnections.value,
223
+ activeConnections: this.activeConnections.value,
224
+ messagesReceived: this.messagesReceived.value,
225
+ messagesSent: this.messagesSent.value,
226
+ errors: this.wsErrors.value,
227
+ namespaces: namespaceStats,
228
+ rooms: this.roomManager.getAllRooms()
229
+ };
230
+ }
231
+ beforeStart(_app, container) {
232
+ this.container = container;
233
+ for (const controllerClass of wsControllerRegistry) {
234
+ const namespace = Reflect.getMetadata(WS_METADATA.WS_CONTROLLER, controllerClass);
235
+ if (namespace === void 0) continue;
236
+ const handlers = Reflect.getMetadata(WS_METADATA.WS_HANDLERS, controllerClass) || [];
237
+ const fullPath = this.basePath + (namespace === "/" ? "" : namespace);
238
+ this.namespaces.set(fullPath, {
239
+ namespace,
240
+ controllerClass,
241
+ handlers,
242
+ sockets: /* @__PURE__ */ new Map(),
243
+ contexts: /* @__PURE__ */ new Map()
244
+ });
245
+ log.info(`Registered WS namespace: ${fullPath} (${controllerClass.name})`);
246
+ }
247
+ }
248
+ afterStart(server, _container) {
249
+ this.wss = new WebSocketServer({
250
+ noServer: true,
251
+ maxPayload: this.maxPayload
252
+ });
253
+ server.on("upgrade", (request, socket, head) => {
254
+ const url = request.url || "/";
255
+ const pathname = url.split("?")[0];
256
+ const entry = this.namespaces.get(pathname);
257
+ if (!entry) {
258
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
259
+ socket.destroy();
260
+ return;
261
+ }
262
+ this.wss.handleUpgrade(request, socket, head, (ws) => {
263
+ this.handleConnection(ws, entry);
264
+ });
265
+ });
266
+ if (this.heartbeatInterval > 0) {
267
+ this.heartbeatTimer = setInterval(() => {
268
+ for (const [, entry] of this.namespaces) {
269
+ for (const [, socket] of entry.sockets) {
270
+ if (socket.__alive === false) {
271
+ socket.terminate();
272
+ continue;
273
+ }
274
+ ;
275
+ socket.__alive = false;
276
+ socket.ping();
277
+ }
278
+ }
279
+ }, this.heartbeatInterval);
280
+ }
281
+ const totalHandlers = Array.from(this.namespaces.values()).reduce((sum, e) => sum + e.handlers.length, 0);
282
+ log.info(`WebSocket ready \u2014 ${this.namespaces.size} namespace(s), ${totalHandlers} handler(s)`);
283
+ }
284
+ shutdown() {
285
+ if (this.heartbeatTimer) {
286
+ clearInterval(this.heartbeatTimer);
287
+ }
288
+ for (const [, entry] of this.namespaces) {
289
+ for (const [, socket] of entry.sockets) {
290
+ socket.close(1001, "Server shutting down");
291
+ }
292
+ entry.sockets.clear();
293
+ entry.contexts.clear();
294
+ }
295
+ this.wss?.close();
296
+ }
297
+ // ── Internal ───────────────────────────────────────────────────────────
298
+ handleConnection(ws, entry) {
299
+ const socketId = randomUUID();
300
+ ws.__alive = true;
301
+ entry.sockets.set(socketId, ws);
302
+ this.totalConnections.value++;
303
+ this.activeConnections.value++;
304
+ const ctx = new WsContext(ws, this.wss, this.roomManager, entry.sockets, socketId, entry.namespace);
305
+ entry.contexts.set(socketId, ctx);
306
+ const controller = this.container.resolve(entry.controllerClass);
307
+ ws.on("pong", () => {
308
+ ;
309
+ ws.__alive = true;
310
+ });
311
+ this.invokeHandlers(controller, entry.handlers, "connect", ctx);
312
+ ws.on("message", (raw) => {
313
+ this.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", {
320
+ message: 'Invalid message format: missing "event" field'
321
+ });
322
+ return;
323
+ }
324
+ ctx.event = event;
325
+ ctx.data = data;
326
+ const handler = entry.handlers.find((h) => h.type === "message" && h.event === event);
327
+ if (handler) {
328
+ this.safeInvoke(controller, handler.handlerName, ctx);
329
+ } else {
330
+ const catchAll = entry.handlers.find((h) => h.type === "message" && h.event === "*");
331
+ if (catchAll) {
332
+ this.safeInvoke(controller, catchAll.handlerName, ctx);
333
+ }
334
+ }
335
+ } catch {
336
+ ctx.data = {
337
+ message: "Invalid JSON"
338
+ };
339
+ this.invokeHandlers(controller, entry.handlers, "error", ctx);
340
+ }
341
+ });
342
+ ws.on("close", () => {
343
+ this.activeConnections.value--;
344
+ this.invokeHandlers(controller, entry.handlers, "disconnect", ctx);
345
+ this.roomManager.leaveAll(socketId);
346
+ entry.sockets.delete(socketId);
347
+ entry.contexts.delete(socketId);
348
+ });
349
+ ws.on("error", (err) => {
350
+ this.wsErrors.value++;
351
+ ctx.data = {
352
+ message: err.message,
353
+ name: err.name
354
+ };
355
+ this.invokeHandlers(controller, entry.handlers, "error", ctx);
356
+ });
357
+ }
358
+ invokeHandlers(controller, handlers, type, ctx) {
359
+ for (const handler of handlers) {
360
+ if (handler.type === type) {
361
+ this.safeInvoke(controller, handler.handlerName, ctx);
362
+ }
363
+ }
364
+ }
365
+ safeInvoke(controller, method, ctx) {
366
+ try {
367
+ const result = controller[method](ctx);
368
+ if (result instanceof Promise) {
369
+ result.catch((err) => {
370
+ log.error({
371
+ err
372
+ }, `WS handler error in ${method}`);
373
+ });
374
+ }
375
+ } catch (err) {
376
+ log.error({
377
+ err
378
+ }, `WS handler error in ${method}`);
379
+ }
380
+ }
381
+ };
382
+
383
+ // src/decorators.ts
384
+ import "reflect-metadata";
385
+ import { Service } from "@forinda/kickjs-core";
386
+ function WsController(namespace) {
387
+ return (target) => {
388
+ Service()(target);
389
+ Reflect.defineMetadata(WS_METADATA.WS_CONTROLLER, namespace || "/", target);
390
+ wsControllerRegistry.add(target);
391
+ };
392
+ }
393
+ __name(WsController, "WsController");
394
+ function createWsHandlerDecorator(type, event) {
395
+ return () => {
396
+ return (target, propertyKey) => {
397
+ const handlers = Reflect.getMetadata(WS_METADATA.WS_HANDLERS, target.constructor) || [];
398
+ handlers.push({
399
+ type,
400
+ event,
401
+ handlerName: propertyKey
402
+ });
403
+ Reflect.defineMetadata(WS_METADATA.WS_HANDLERS, handlers, target.constructor);
404
+ };
405
+ };
406
+ }
407
+ __name(createWsHandlerDecorator, "createWsHandlerDecorator");
408
+ var OnConnect = createWsHandlerDecorator("connect");
409
+ var OnDisconnect = createWsHandlerDecorator("disconnect");
410
+ var OnError = createWsHandlerDecorator("error");
411
+ function OnMessage(event) {
412
+ return (target, propertyKey) => {
413
+ const handlers = Reflect.getMetadata(WS_METADATA.WS_HANDLERS, target.constructor) || [];
414
+ handlers.push({
415
+ type: "message",
416
+ event,
417
+ handlerName: propertyKey
418
+ });
419
+ Reflect.defineMetadata(WS_METADATA.WS_HANDLERS, handlers, target.constructor);
420
+ };
421
+ }
422
+ __name(OnMessage, "OnMessage");
423
+ export {
424
+ OnConnect,
425
+ OnDisconnect,
426
+ OnError,
427
+ OnMessage,
428
+ RoomManager,
429
+ WS_METADATA,
430
+ WsAdapter,
431
+ WsContext,
432
+ WsController
433
+ };
434
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/ws-adapter.ts","../src/interfaces.ts","../src/ws-context.ts","../src/room-manager.ts","../src/decorators.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto'\nimport { WebSocketServer, type WebSocket } from 'ws'\nimport type { IncomingMessage } from 'node:http'\nimport type { Duplex } from 'node:stream'\nimport { type AppAdapter, type Container, createLogger, ref, type Ref } from '@forinda/kickjs-core'\nimport {\n WS_METADATA,\n wsControllerRegistry,\n type WsAdapterOptions,\n type WsHandlerDefinition,\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 * 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 * new 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 class WsAdapter implements AppAdapter {\n readonly name = 'WsAdapter'\n\n private basePath: string\n private heartbeatInterval: number\n private maxPayload: number | undefined\n private wss: WebSocketServer | null = null\n private container: Container | null = null\n private namespaces = new Map<string, NamespaceEntry>()\n private roomManager = new RoomManager()\n private heartbeatTimer: ReturnType<typeof setInterval> | null = null\n\n // ── Reactive Stats (exposed for DevToolsAdapter) ─────────────────────\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 constructor(options: WsAdapterOptions = {}) {\n this.basePath = options.path ?? '/ws'\n this.heartbeatInterval = options.heartbeatInterval ?? 30000\n this.maxPayload = options.maxPayload\n\n this.totalConnections = ref(0)\n this.activeConnections = ref(0)\n this.messagesReceived = ref(0)\n this.messagesSent = ref(0)\n this.wsErrors = ref(0)\n }\n\n /** Get a snapshot of WebSocket stats for DevTools */\n getStats() {\n const namespaceStats: Record<string, { connections: number; handlers: number }> = {}\n for (const [path, entry] of this.namespaces) {\n namespaceStats[path] = {\n connections: entry.sockets.size,\n handlers: entry.handlers.length,\n }\n }\n return {\n totalConnections: this.totalConnections.value,\n activeConnections: this.activeConnections.value,\n messagesReceived: this.messagesReceived.value,\n messagesSent: this.messagesSent.value,\n errors: this.wsErrors.value,\n namespaces: namespaceStats,\n rooms: this.roomManager.getAllRooms(),\n }\n }\n\n beforeStart(_app: any, container: Container): void {\n this.container = container\n\n // Discover all @WsController classes and build routing table\n for (const controllerClass of wsControllerRegistry) {\n const namespace: string | undefined = Reflect.getMetadata(\n WS_METADATA.WS_CONTROLLER,\n controllerClass,\n )\n if (namespace === undefined) continue\n\n const handlers: WsHandlerDefinition[] =\n Reflect.getMetadata(WS_METADATA.WS_HANDLERS, controllerClass) || []\n\n const fullPath = this.basePath + (namespace === '/' ? '' : namespace)\n\n this.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: any, _container: Container): void {\n this.wss = new WebSocketServer({\n noServer: true,\n maxPayload: this.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 = this.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 this.wss!.handleUpgrade(request, socket, head, (ws) => {\n this.handleConnection(ws, entry)\n })\n })\n\n // Heartbeat ping/pong\n if (this.heartbeatInterval > 0) {\n this.heartbeatTimer = setInterval(() => {\n for (const [, entry] of this.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 }, this.heartbeatInterval)\n }\n\n const totalHandlers = Array.from(this.namespaces.values()).reduce(\n (sum, e) => sum + e.handlers.length,\n 0,\n )\n log.info(`WebSocket ready — ${this.namespaces.size} namespace(s), ${totalHandlers} handler(s)`)\n }\n\n shutdown(): void {\n if (this.heartbeatTimer) {\n clearInterval(this.heartbeatTimer)\n }\n\n // Close all connections\n for (const [, entry] of this.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 this.wss?.close()\n }\n\n // ── Internal ───────────────────────────────────────────────────────────\n\n private handleConnection(ws: WebSocket, entry: NamespaceEntry): void {\n const socketId = randomUUID()\n ;(ws as any).__alive = true\n\n entry.sockets.set(socketId, ws)\n this.totalConnections.value++\n this.activeConnections.value++\n\n const ctx = new WsContext(\n ws,\n this.wss!,\n this.roomManager,\n entry.sockets,\n socketId,\n entry.namespace,\n )\n entry.contexts.set(socketId, ctx)\n\n // Resolve controller from DI\n const controller = this.container!.resolve(entry.controllerClass)\n\n // Pong handler for heartbeat\n ws.on('pong', () => {\n ;(ws as any).__alive = true\n })\n\n // @OnConnect handlers\n this.invokeHandlers(controller, entry.handlers, 'connect', ctx)\n\n // Message handler\n ws.on('message', (raw: Buffer | string) => {\n this.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 // Find matching @OnMessage handler\n const handler = entry.handlers.find((h) => h.type === 'message' && h.event === event)\n\n if (handler) {\n this.safeInvoke(controller, handler.handlerName, ctx)\n } else {\n // Try catch-all @OnMessage('*')\n const catchAll = entry.handlers.find((h) => h.type === 'message' && h.event === '*')\n if (catchAll) {\n this.safeInvoke(controller, catchAll.handlerName, ctx)\n }\n }\n } catch {\n ctx.data = { message: 'Invalid JSON' }\n this.invokeHandlers(controller, entry.handlers, 'error', ctx)\n }\n })\n\n // Close handler\n ws.on('close', () => {\n this.activeConnections.value--\n this.invokeHandlers(controller, entry.handlers, 'disconnect', ctx)\n this.roomManager.leaveAll(socketId)\n entry.sockets.delete(socketId)\n entry.contexts.delete(socketId)\n })\n\n // Error handler\n ws.on('error', (err: Error) => {\n this.wsErrors.value++\n ctx.data = { message: err.message, name: err.name }\n this.invokeHandlers(controller, entry.handlers, 'error', ctx)\n })\n }\n\n private 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 this.safeInvoke(controller, handler.handlerName, ctx)\n }\n }\n }\n\n private 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","type Constructor = new (...args: any[]) => any\n\nexport const WS_METADATA = {\n WS_CONTROLLER: Symbol('kick:ws:controller'),\n WS_HANDLERS: Symbol('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\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}\n\n/** Registry of all @WsController classes — populated at decorator time */\nexport const wsControllerRegistry = new Set<Constructor>()\n","import 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\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 ) {\n this.id = id\n this.namespace = namespace\n this.data = null\n this.event = ''\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 'reflect-metadata'\nimport { Service } from '@forinda/kickjs-core'\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 Reflect.defineMetadata(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 const handlers: WsHandlerDefinition[] =\n Reflect.getMetadata(WS_METADATA.WS_HANDLERS, target.constructor) || []\n handlers.push({\n type,\n event,\n handlerName: propertyKey as string,\n })\n Reflect.defineMetadata(WS_METADATA.WS_HANDLERS, handlers, target.constructor)\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 const handlers: WsHandlerDefinition[] =\n Reflect.getMetadata(WS_METADATA.WS_HANDLERS, target.constructor) || []\n handlers.push({\n type: 'message',\n event,\n handlerName: propertyKey as string,\n })\n Reflect.defineMetadata(WS_METADATA.WS_HANDLERS, handlers, target.constructor)\n }\n}\n"],"mappings":"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","names":["randomUUID","WebSocketServer","createLogger","ref","WS_METADATA","WS_CONTROLLER","Symbol","WS_HANDLERS","wsControllerRegistry","Set","WsContext","id","data","event","namespace","metadata","Map","socket","server","roomManager","namespaceSockets","get","key","set","value","send","readyState","OPEN","JSON","stringify","broadcast","message","broadcastAll","join","room","leave","rooms","getRooms","to","RoomManager","socketRooms","Map","roomSockets","join","socketId","socket","room","has","set","Set","get","add","leave","delete","size","leaveAll","rooms","getRooms","Array","from","getSockets","getAllRooms","result","sockets","broadcast","event","data","excludeId","message","JSON","stringify","id","readyState","OPEN","send","log","createLogger","WsAdapter","name","basePath","heartbeatInterval","maxPayload","wss","container","namespaces","Map","roomManager","RoomManager","heartbeatTimer","totalConnections","activeConnections","messagesReceived","messagesSent","wsErrors","options","path","ref","getStats","namespaceStats","entry","connections","sockets","size","handlers","length","value","errors","rooms","getAllRooms","beforeStart","_app","controllerClass","wsControllerRegistry","namespace","Reflect","getMetadata","WS_METADATA","WS_CONTROLLER","undefined","WS_HANDLERS","fullPath","set","contexts","info","afterStart","server","_container","WebSocketServer","noServer","on","request","socket","head","url","pathname","split","get","write","destroy","handleUpgrade","ws","handleConnection","setInterval","__alive","terminate","ping","totalHandlers","Array","from","values","reduce","sum","e","shutdown","clearInterval","close","clear","socketId","randomUUID","ctx","WsContext","controller","resolve","invokeHandlers","raw","parsed","JSON","parse","toString","event","data","send","message","handler","find","h","type","safeInvoke","handlerName","catchAll","leaveAll","delete","err","method","result","Promise","catch","error","Service","WsController","namespace","target","Service","Reflect","defineMetadata","WS_METADATA","WS_CONTROLLER","wsControllerRegistry","add","createWsHandlerDecorator","type","event","propertyKey","handlers","getMetadata","WS_HANDLERS","push","handlerName","OnConnect","OnDisconnect","OnError","OnMessage"]}
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@forinda/kickjs-ws",
3
+ "version": "0.5.0",
4
+ "description": "WebSocket support with decorators, namespaces, rooms, and DI integration for KickJS",
5
+ "keywords": [
6
+ "kickjs",
7
+ "nodejs",
8
+ "typescript",
9
+ "express",
10
+ "decorators",
11
+ "dependency-injection",
12
+ "backend",
13
+ "api",
14
+ "framework",
15
+ "websocket",
16
+ "ws",
17
+ "realtime",
18
+ "rooms",
19
+ "namespaces",
20
+ "@forinda/kickjs-core",
21
+ "@forinda/kickjs-http",
22
+ "@forinda/kickjs-config",
23
+ "@forinda/kickjs-cli",
24
+ "@forinda/kickjs-swagger",
25
+ "@forinda/kickjs-testing",
26
+ "@forinda/kickjs-prisma",
27
+ "@forinda/kickjs-ws"
28
+ ],
29
+ "type": "module",
30
+ "main": "dist/index.js",
31
+ "types": "dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "import": "./dist/index.js",
35
+ "types": "./dist/index.d.ts"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist"
40
+ ],
41
+ "dependencies": {
42
+ "reflect-metadata": "^0.2.2",
43
+ "ws": "^8.18.0",
44
+ "@forinda/kickjs-core": "0.5.0"
45
+ },
46
+ "devDependencies": {
47
+ "@swc/core": "^1.7.28",
48
+ "@types/node": "^24.5.2",
49
+ "@types/ws": "^8.18.0",
50
+ "tsup": "^8.5.0",
51
+ "typescript": "^5.9.2",
52
+ "vitest": "^3.2.4"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "license": "MIT",
58
+ "author": "Felix Orinda",
59
+ "engines": {
60
+ "node": ">=20.0"
61
+ },
62
+ "homepage": "https://forinda.github.io/kick-js/",
63
+ "repository": {
64
+ "type": "git",
65
+ "url": "https://github.com/forinda/kick-js.git",
66
+ "directory": "packages/ws"
67
+ },
68
+ "bugs": {
69
+ "url": "https://github.com/forinda/kick-js/issues"
70
+ },
71
+ "scripts": {
72
+ "build": "tsup",
73
+ "dev": "tsup --watch",
74
+ "test": "vitest run",
75
+ "test:watch": "vitest",
76
+ "typecheck": "tsc --noEmit",
77
+ "clean": "rm -rf dist .turbo",
78
+ "lint": "tsc --noEmit"
79
+ }
80
+ }