@forinda/kickjs-ws 7.0.2 → 7.1.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/README.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # @forinda/kickjs-ws
2
2
 
3
- WebSocket adapter for KickJS — decorator-driven handlers (`@WsController`, `@OnConnect`, `@OnDisconnect`, `@OnMessage`, `@OnError`), namespaces, rooms, heartbeat, optional auth resolver.
3
+ Realtime for KickJS — decorator-driven handlers (`@WsController`, `@OnConnect`, `@OnDisconnect`, `@OnMessage`, `@OnError`), namespaces, rooms, heartbeat and an auth hook, on the transport you pick.
4
+
5
+ | Import | What it gives you | Needs |
6
+ | ------------------------------- | ------------------------------------------------------------- | ----------------------------- |
7
+ | `@forinda/kickjs-ws` | `WsAdapter` — raw WebSockets on `ws` | `ws` |
8
+ | `@forinda/kickjs-ws/redis` | `redisBroker` — relay `WsAdapter` broadcasts across instances | a Redis client (e.g. ioredis) |
9
+ | `@forinda/kickjs-ws/socket.io` | `SocketIoAdapter` — the same controllers over Socket.IO | `socket.io` |
10
+ | `@forinda/kickjs-ws/centrifugo` | `CentrifugoAdapter` — publish through a Centrifugo server | a Centrifugo server |
4
11
 
5
12
  ## Install
6
13
 
@@ -42,15 +49,61 @@ export const app = await bootstrap({
42
49
 
43
50
  Clients connect to `ws://localhost:3000/ws/chat`.
44
51
 
45
- ## Limits
52
+ ## More than one instance
53
+
54
+ Rooms and broadcasts reach sockets in one process unless you pass a `broker`:
55
+
56
+ ```ts
57
+ import Redis from 'ioredis'
58
+ import { getEnv } from '@forinda/kickjs'
59
+ import { WsAdapter } from '@forinda/kickjs-ws'
60
+ import { redisBroker } from '@forinda/kickjs-ws/redis'
61
+
62
+ const redis = new Redis(getEnv('REDIS_URL'))
63
+
64
+ WsAdapter({ broker: redisBroker({ publisher: redis, subscriber: redis.duplicate() }) })
65
+ ```
66
+
67
+ ## Socket.IO
68
+
69
+ The same controllers run on Socket.IO:
70
+
71
+ ```ts
72
+ import { SocketIoAdapter } from '@forinda/kickjs-ws/socket.io'
73
+
74
+ bootstrap({ modules, adapters: [SocketIoAdapter({ cors: { origin: 'https://app.example.com' } })] })
75
+ ```
76
+
77
+ Rooms are per namespace, `WS_ROOM_MANAGER` is replaced by the `SOCKET_IO` token, and it scales with Socket.IO's own adapters (`@socket.io/redis-adapter`).
78
+
79
+ ## Centrifugo
80
+
81
+ Centrifugo holds the connections; KickJS signs connection tokens, answers its connect proxy, and publishes:
82
+
83
+ ```ts
84
+ import { bootstrap, getEnv } from '@forinda/kickjs'
85
+ import { CentrifugoAdapter } from '@forinda/kickjs-ws/centrifugo'
86
+
87
+ bootstrap({
88
+ modules,
89
+ adapters: [
90
+ CentrifugoAdapter({ url: getEnv('CENTRIFUGO_URL'), apiKey: getEnv('CENTRIFUGO_API_KEY') }),
91
+ ],
92
+ })
93
+ ```
94
+
95
+ Services inject `CENTRIFUGO` to publish. `@WsController` classes do not apply — the sockets are not in Node.
96
+
97
+ ## Common to all
46
98
 
47
- Runs in **one Node process** under `bootstrap()`. Rooms and per-user broadcasts
48
- reach sockets in that process only, so a second instance is a second island. It
49
- does not run on the `@forinda/kickjs/web` edge entry (Workers, Bun, Deno).
99
+ - `WS_USER_BROADCASTER` is registered by every adapter, so `broadcastToUser(userId, event, data)` works unchanged when you switch transports.
100
+ - Adapters run under Node `bootstrap()`, not the `@forinda/kickjs/web` edge entry (Workers, Bun, Deno).
50
101
 
51
102
  ## Documentation
52
103
 
53
- [kickjs.app/guide/websockets](https://kickjs.app/guide/websockets)
104
+ - [WebSockets](https://kickjs.app/guide/websockets)
105
+ - [Socket.IO](https://kickjs.app/guide/socketio)
106
+ - [Centrifugo](https://kickjs.app/guide/centrifugo)
54
107
 
55
108
  ## License
56
109
 
@@ -0,0 +1,99 @@
1
+
2
+ import { s as WsAuthenticatedUser } from "./interfaces-Bw3lVCoj.mjs";
3
+ //#region src/centrifugo.d.ts
4
+ export interface CentrifugoClientOptions {
5
+ /** Centrifugo base URL, e.g. `http://centrifugo:8000`. The client calls `<url>/api/<method>`. */
6
+ url: string;
7
+ /** `http_api.key` from the Centrifugo config, sent as `X-API-Key`. */
8
+ apiKey: string;
9
+ /** Defaults to the global `fetch`. */
10
+ fetch?: typeof fetch;
11
+ /**
12
+ * Abort a call not finished after this many milliseconds (default 10 000);
13
+ * it rejects with a `TimeoutError`. Without it a stalled Centrifugo keeps
14
+ * callers — and fire-and-forget user broadcasts — pending.
15
+ */
16
+ timeoutMs?: number;
17
+ }
18
+ /** Centrifugo server API — the subset KickJS services need. */
19
+ export interface CentrifugoClient {
20
+ /** Publish `data` (any JSON value) to one channel. */
21
+ publish(channel: string, data: unknown): Promise<void>;
22
+ /** Publish the same `data` to several channels in one call. */
23
+ broadcast(channels: string[], data: unknown): Promise<void>;
24
+ /** Subscribe every connection of `user` to `channel`, server-side. */
25
+ subscribe(user: string, channel: string): Promise<void>;
26
+ /** Disconnect every connection of `user`. */
27
+ disconnect(user: string): Promise<void>;
28
+ }
29
+ /** Thrown when Centrifugo answers non-2xx, or 200 with an `error` object. */
30
+ export declare class CentrifugoApiError extends Error {
31
+ readonly method: string;
32
+ readonly code: number;
33
+ constructor(method: string, code: number, message: string);
34
+ }
35
+ export declare function centrifugoClient({ url, apiKey, fetch: fetchImpl, timeoutMs }: CentrifugoClientOptions): CentrifugoClient;
36
+ export interface ConnectionTokenOptions {
37
+ /** `client.token.hmac_secret_key` from the Centrifugo config. */
38
+ secret: string;
39
+ /** User id. An empty string connects anonymously, if Centrifugo allows it. */
40
+ sub: string;
41
+ /** Token lifetime. Omit for a token that never expires. */
42
+ expiresInSeconds?: number;
43
+ /** Connection info, visible to other clients in presence and join/leave events. */
44
+ info?: unknown;
45
+ /** Channels to subscribe the connection to server-side. */
46
+ channels?: string[];
47
+ }
48
+ /**
49
+ * An HS256 connection JWT. Claims follow Centrifugo's `ConnectTokenClaims`
50
+ * (internal/jwtverify/token_verifier_jwt.go): `sub`, `exp`, `info`, `channels`.
51
+ */
52
+ export declare function connectionToken({ secret, sub, expiresInSeconds, info, channels }: ConnectionTokenOptions): string;
53
+ /** Body a connect proxy endpoint returns (internal/proxyproto/proxy.proto `ConnectResponse`). */
54
+ export type CentrifugoConnectReply = {
55
+ result: {
56
+ user: string;
57
+ };
58
+ } | {
59
+ disconnect: {
60
+ code: number;
61
+ reason: string;
62
+ };
63
+ } | {
64
+ error: {
65
+ code: number;
66
+ message: string;
67
+ };
68
+ };
69
+ /**
70
+ * Answer Centrifugo's connect proxy with the same `resolveUser` a `WsAdapter`
71
+ * uses. A user connects; `null` or a user without an id disconnects with
72
+ * `4401` (the code `WsAdapter` closes with); a throwing resolver answers
73
+ * error `100`, Centrifugo's internal error, so the client may retry.
74
+ *
75
+ * Centrifugo forwards only the headers listed in
76
+ * `client.proxy.connect.http_headers` — add `Cookie` or `Authorization` there,
77
+ * or `resolveUser` sees none.
78
+ */
79
+ export declare function centrifugoConnect<Req>(request: Req, resolveUser: (request: Req) => Promise<WsAuthenticatedUser | null> | WsAuthenticatedUser | null): Promise<CentrifugoConnectReply>;
80
+ /** DI token for the {@link CentrifugoClient} registered by {@link CentrifugoAdapter}. */
81
+ export declare const CENTRIFUGO: import("@forinda/kickjs").InjectionToken<CentrifugoClient>;
82
+ export interface CentrifugoAdapterOptions extends CentrifugoClientOptions {
83
+ /**
84
+ * `client.subscribe_to_user_personal_channel.personal_channel_namespace`.
85
+ * Leave unset when that option is unset: the personal channel is then
86
+ * `#<user>`, otherwise `<namespace>:#<user>` (internal/config/container.go).
87
+ */
88
+ personalChannelNamespace?: string;
89
+ }
90
+ /**
91
+ * Registers {@link CENTRIFUGO} and a `WS_USER_BROADCASTER` that publishes to
92
+ * the user's personal channel, so services written against `WsAdapter`'s
93
+ * broadcaster keep working. Enable
94
+ * `client.subscribe_to_user_personal_channel` in Centrifugo so users are
95
+ * subscribed to that channel on connect.
96
+ */
97
+ export declare const CentrifugoAdapter: import("@forinda/kickjs").AdapterFactory<CentrifugoAdapterOptions, unknown>;
98
+ //#endregion
99
+ //# sourceMappingURL=centrifugo.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"centrifugo.d.mts","names":[],"sources":["../src/centrifugo.ts"],"mappings":";;;iBA4BiB;;EAEf;;EAEA;;EAEA,eAAe;;;;;;EAMf;;;iBAIe;;EAEf,QAAQ,iBAAiB,gBAAgB;;EAEzC,UAAU,oBAAoB,gBAAgB;;EAE9C,UAAU,cAAc,kBAAkB;;EAE1C,WAAW,eAAe;;;qBAIf,2BAA2B;WAE3B;WACA;EAFX,YACW,gBACA,cACT;;wBAOY,mBACd,KACA,QACA,OAAO,WACP,aACC,0BAA0B;iBA6BZ;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;wBASc,kBACd,QACA,KACA,kBACA,MACA,YACC;;YAcS;EACN;IAAU;;;EACV;IAAc;IAAc;;;EAC5B;IAAS;IAAc;;;;;;;;;;;;;wBAYP,kBAAkB,KACtC,SAAS,KACT,cAAc,SAAS,QAAQ,QAAQ,8BAA8B,6BACpE,QAAQ;;qBAYE,sCAAU,eAAA;iBAEN,iCAAiC;;;;;;EAMhD;;;;;;;;;qBAUW,6CAAiB,eAAA"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @forinda/kickjs-ws v7.1.1
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{i as WS_USER_BROADCASTER}from"./interfaces-DsBi7cdB.mjs";import{createHmac}from"node:crypto";import{createLogger,createToken,defineAdapter}from"@forinda/kickjs";const log=createLogger(`CentrifugoAdapter`);var CentrifugoApiError=class extends Error{method;code;constructor(method,code,message){super(`Centrifugo ${method} failed (${code}): ${message}`),this.method=method,this.code=code,this.name=`CentrifugoApiError`}};function centrifugoClient({url,apiKey,fetch:fetchImpl=globalThis.fetch,timeoutMs=1e4}){let base=url.replace(/\/+$/,``),call=async(method,body)=>{let res=await fetchImpl(`${base}/api/${method}`,{method:`POST`,headers:{"Content-Type":`application/json`,"X-API-Key":apiKey},body:JSON.stringify(body),signal:AbortSignal.timeout(timeoutMs)});if(!res.ok)throw new CentrifugoApiError(method,res.status,res.statusText||`HTTP error`);let reply=await res.json().catch(()=>({}));if(reply.error)throw new CentrifugoApiError(method,reply.error.code,reply.error.message)};return{publish:(channel,data)=>call(`publish`,{channel,data}),broadcast:(channels,data)=>call(`broadcast`,{channels,data}),subscribe:(user,channel)=>call(`subscribe`,{user,channel}),disconnect:user=>call(`disconnect`,{user})}}const base64url=input=>Buffer.from(input).toString(`base64url`);function connectionToken({secret,sub,expiresInSeconds,info,channels}){let claims={sub};expiresInSeconds!==void 0&&(claims.exp=Math.floor(Date.now()/1e3)+expiresInSeconds),info!==void 0&&(claims.info=info),channels!==void 0&&(claims.channels=channels);let unsigned=`${base64url(JSON.stringify({alg:`HS256`,typ:`JWT`}))}.${base64url(JSON.stringify(claims))}`;return`${unsigned}.${createHmac(`sha256`,secret).update(unsigned).digest(`base64url`)}`}async function centrifugoConnect(request,resolveUser){try{let user=await resolveUser(request);return!user||!user.id?{disconnect:{code:4401,reason:`unauthorized`}}:{result:{user:String(user.id)}}}catch(err){return log.error({err},`Centrifugo connect proxy: resolveUser threw`),{error:{code:100,message:`internal server error`}}}}const CENTRIFUGO=createToken(`kick/ws/Centrifugo`),CentrifugoAdapter=defineAdapter({name:`CentrifugoAdapter`,build:options=>{let client=centrifugoClient(options),roomFor=userId=>options.personalChannelNamespace?`${options.personalChannelNamespace}:#${userId}`:`#${userId}`,broadcastToUser=(userId,event,data)=>{client.publish(roomFor(userId),{event,data}).catch(err=>log.error({err},`Centrifugo publish failed`))},broadcaster={roomFor,broadcastToUser,toUser:id=>({send:(event,data)=>broadcastToUser(id,event,data)})};return{beforeStart({container}){container.registerInstance(CENTRIFUGO,client),container.registerInstance(WS_USER_BROADCASTER,broadcaster)}}}});export{CENTRIFUGO,CentrifugoAdapter,CentrifugoApiError,centrifugoClient,centrifugoConnect,connectionToken};
12
+ //# sourceMappingURL=centrifugo.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"centrifugo.mjs","names":[],"sources":["../src/centrifugo.ts"],"sourcesContent":["/**\n * Centrifugo integration. Centrifugo holds the client connections; KickJS\n * issues connection tokens (or answers its connect proxy) and publishes\n * through its server API.\n *\n * ```ts\n * import { bootstrap, getEnv } from '@forinda/kickjs'\n * import { CentrifugoAdapter } from '@forinda/kickjs-ws/centrifugo'\n *\n * bootstrap({\n * modules,\n * adapters: [\n * CentrifugoAdapter({ url: getEnv('CENTRIFUGO_URL'), apiKey: getEnv('CENTRIFUGO_API_KEY') }),\n * ],\n * })\n * ```\n *\n * `@WsController` / `@OnMessage` do not apply here: those handle sockets the\n * Node process owns, and with Centrifugo it owns none.\n *\n * @module @forinda/kickjs-ws/centrifugo\n */\nimport { createHmac } from 'node:crypto'\nimport { createLogger, createToken, defineAdapter } from '@forinda/kickjs'\nimport { WS_USER_BROADCASTER, type WsAuthenticatedUser, type WsUserBroadcaster } from './interfaces'\n\nconst log = createLogger('CentrifugoAdapter')\n\nexport interface CentrifugoClientOptions {\n /** Centrifugo base URL, e.g. `http://centrifugo:8000`. The client calls `<url>/api/<method>`. */\n url: string\n /** `http_api.key` from the Centrifugo config, sent as `X-API-Key`. */\n apiKey: string\n /** Defaults to the global `fetch`. */\n fetch?: typeof fetch\n /**\n * Abort a call not finished after this many milliseconds (default 10 000);\n * it rejects with a `TimeoutError`. Without it a stalled Centrifugo keeps\n * callers — and fire-and-forget user broadcasts — pending.\n */\n timeoutMs?: number\n}\n\n/** Centrifugo server API — the subset KickJS services need. */\nexport interface CentrifugoClient {\n /** Publish `data` (any JSON value) to one channel. */\n publish(channel: string, data: unknown): Promise<void>\n /** Publish the same `data` to several channels in one call. */\n broadcast(channels: string[], data: unknown): Promise<void>\n /** Subscribe every connection of `user` to `channel`, server-side. */\n subscribe(user: string, channel: string): Promise<void>\n /** Disconnect every connection of `user`. */\n disconnect(user: string): Promise<void>\n}\n\n/** Thrown when Centrifugo answers non-2xx, or 200 with an `error` object. */\nexport class CentrifugoApiError extends Error {\n constructor(\n readonly method: string,\n readonly code: number,\n message: string,\n ) {\n super(`Centrifugo ${method} failed (${code}): ${message}`)\n this.name = 'CentrifugoApiError'\n }\n}\n\nexport function centrifugoClient({\n url,\n apiKey,\n fetch: fetchImpl = globalThis.fetch,\n timeoutMs = 10_000,\n}: CentrifugoClientOptions): CentrifugoClient {\n const base = url.replace(/\\/+$/, '')\n\n const call = async (method: string, body: Record<string, unknown>): Promise<void> => {\n const res = await fetchImpl(`${base}/api/${method}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },\n body: JSON.stringify(body),\n // Covers the response body too: reading it below aborts on the same signal.\n signal: AbortSignal.timeout(timeoutMs),\n })\n if (!res.ok) throw new CentrifugoApiError(method, res.status, res.statusText || 'HTTP error')\n // By default Centrifugo reports API errors with HTTP 200 and an `error`\n // object in the body (internal/api/handler_gen.go); only the opt-in\n // transport error mode maps them to HTTP status codes.\n const reply = (await res.json().catch(() => ({}))) as {\n error?: { code: number; message: string }\n }\n if (reply.error) throw new CentrifugoApiError(method, reply.error.code, reply.error.message)\n }\n\n return {\n publish: (channel, data) => call('publish', { channel, data }),\n broadcast: (channels, data) => call('broadcast', { channels, data }),\n subscribe: (user, channel) => call('subscribe', { user, channel }),\n disconnect: (user) => call('disconnect', { user }),\n }\n}\n\nexport interface ConnectionTokenOptions {\n /** `client.token.hmac_secret_key` from the Centrifugo config. */\n secret: string\n /** User id. An empty string connects anonymously, if Centrifugo allows it. */\n sub: string\n /** Token lifetime. Omit for a token that never expires. */\n expiresInSeconds?: number\n /** Connection info, visible to other clients in presence and join/leave events. */\n info?: unknown\n /** Channels to subscribe the connection to server-side. */\n channels?: string[]\n}\n\nconst base64url = (input: string | Buffer): string => Buffer.from(input).toString('base64url')\n\n/**\n * An HS256 connection JWT. Claims follow Centrifugo's `ConnectTokenClaims`\n * (internal/jwtverify/token_verifier_jwt.go): `sub`, `exp`, `info`, `channels`.\n */\nexport function connectionToken({\n secret,\n sub,\n expiresInSeconds,\n info,\n channels,\n}: ConnectionTokenOptions): string {\n const claims: Record<string, unknown> = { sub }\n if (expiresInSeconds !== undefined) {\n claims.exp = Math.floor(Date.now() / 1000) + expiresInSeconds\n }\n if (info !== undefined) claims.info = info\n if (channels !== undefined) claims.channels = channels\n\n const unsigned = `${base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${base64url(JSON.stringify(claims))}`\n const signature = createHmac('sha256', secret).update(unsigned).digest('base64url')\n return `${unsigned}.${signature}`\n}\n\n/** Body a connect proxy endpoint returns (internal/proxyproto/proxy.proto `ConnectResponse`). */\nexport type CentrifugoConnectReply =\n | { result: { user: string } }\n | { disconnect: { code: number; reason: string } }\n | { error: { code: number; message: string } }\n\n/**\n * Answer Centrifugo's connect proxy with the same `resolveUser` a `WsAdapter`\n * uses. A user connects; `null` or a user without an id disconnects with\n * `4401` (the code `WsAdapter` closes with); a throwing resolver answers\n * error `100`, Centrifugo's internal error, so the client may retry.\n *\n * Centrifugo forwards only the headers listed in\n * `client.proxy.connect.http_headers` — add `Cookie` or `Authorization` there,\n * or `resolveUser` sees none.\n */\nexport async function centrifugoConnect<Req>(\n request: Req,\n resolveUser: (request: Req) => Promise<WsAuthenticatedUser | null> | WsAuthenticatedUser | null,\n): Promise<CentrifugoConnectReply> {\n try {\n const user = await resolveUser(request)\n if (!user || !user.id) return { disconnect: { code: 4401, reason: 'unauthorized' } }\n return { result: { user: String(user.id) } }\n } catch (err) {\n log.error({ err }, 'Centrifugo connect proxy: resolveUser threw')\n return { error: { code: 100, message: 'internal server error' } }\n }\n}\n\n/** DI token for the {@link CentrifugoClient} registered by {@link CentrifugoAdapter}. */\nexport const CENTRIFUGO = createToken<CentrifugoClient>('kick/ws/Centrifugo')\n\nexport interface CentrifugoAdapterOptions extends CentrifugoClientOptions {\n /**\n * `client.subscribe_to_user_personal_channel.personal_channel_namespace`.\n * Leave unset when that option is unset: the personal channel is then\n * `#<user>`, otherwise `<namespace>:#<user>` (internal/config/container.go).\n */\n personalChannelNamespace?: string\n}\n\n/**\n * Registers {@link CENTRIFUGO} and a `WS_USER_BROADCASTER` that publishes to\n * the user's personal channel, so services written against `WsAdapter`'s\n * broadcaster keep working. Enable\n * `client.subscribe_to_user_personal_channel` in Centrifugo so users are\n * subscribed to that channel on connect.\n */\nexport const CentrifugoAdapter = defineAdapter<CentrifugoAdapterOptions>({\n name: 'CentrifugoAdapter',\n build: (options) => {\n const client = centrifugoClient(options)\n const roomFor = (userId: string): string =>\n options.personalChannelNamespace\n ? `${options.personalChannelNamespace}:#${userId}`\n : `#${userId}`\n\n // The broadcaster interface is synchronous; a failed publish is logged,\n // matching how WsAdapter treats a broker outage.\n const broadcastToUser = (userId: string, event: string, data: unknown): void => {\n client\n .publish(roomFor(userId), { event, data })\n .catch((err) => log.error({ err }, 'Centrifugo publish failed'))\n }\n\n const broadcaster: WsUserBroadcaster = {\n roomFor,\n broadcastToUser,\n toUser: (id) => ({ send: (event, data) => broadcastToUser(id, event, data) }),\n }\n\n return {\n beforeStart({ container }) {\n container.registerInstance(CENTRIFUGO, client)\n container.registerInstance(WS_USER_BROADCASTER, broadcaster)\n },\n }\n },\n})\n"],"mappings":"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"}
package/dist/index.d.mts CHANGED
@@ -1,124 +1,8 @@
1
1
 
2
+ import { a as WsAdapterOptions, c as WsBroker, d as WsHandlerType, f as WsUserBroadcaster, i as WS_USER_BROADCASTER, l as WsBrokerMessage, n as WS_METADATA, o as WsAuthConfig, p as RoomManager, r as WS_ROOM_MANAGER, s as WsAuthenticatedUser, t as WS_ADAPTER, u as WsHandlerDefinition } from "./interfaces-Bw3lVCoj.mjs";
2
3
  import { WebSocket, WebSocketServer } from "ws";
3
4
  import { Ref } from "@forinda/kickjs";
4
5
  import { IncomingMessage } from "node:http";
5
- //#region src/room-manager.d.ts
6
- /**
7
- * Manages WebSocket room membership and broadcasting.
8
- * Standalone from ws/socket.io — can be swapped for socket.io's built-in rooms.
9
- *
10
- * Room names are GLOBAL, not per namespace: a room joined from `/ws/chat` and
11
- * one joined from `/ws/admin` with the same name are the same room. That is
12
- * what lets a service broadcast through `WS_ROOM_MANAGER` and lets `user:<id>`
13
- * reach a user's sockets in every namespace — prefix names (`chat:lobby`) when
14
- * namespaces must not overlap.
15
- *
16
- * Membership lives in this process only. A second instance has its own rooms.
17
- */
18
- export declare class RoomManager {
19
- private readonly onSend?;
20
- /** @param onSend Called with the number of frames each broadcast wrote. */
21
- constructor(onSend?: ((count: number) => void) | undefined);
22
- /** socketId → set of room names */
23
- private socketRooms;
24
- /** room name → set of { socketId, socket } */
25
- private roomSockets;
26
- join(socketId: string, socket: WebSocket, room: string): void;
27
- leave(socketId: string, room: string): void;
28
- /** Remove socket from all rooms (called on disconnect) */
29
- leaveAll(socketId: string): void;
30
- getRooms(socketId: string): string[];
31
- getSockets(room: string): Map<string, WebSocket>;
32
- /** Get all rooms with their member counts */
33
- getAllRooms(): Record<string, number>;
34
- /** Broadcast to all sockets in a room, optionally excluding one */
35
- broadcast(room: string, event: string, data: any, excludeId?: string): void;
36
- }
37
- //#endregion
38
- //#region src/interfaces.d.ts
39
- export declare const WS_METADATA: {
40
- readonly WS_CONTROLLER: 'kick/ws/controller';
41
- readonly WS_HANDLERS: 'kick/ws/handlers';
42
- };
43
- type WsHandlerType = 'connect' | 'disconnect' | 'message' | 'error';
44
- interface WsHandlerDefinition {
45
- type: WsHandlerType;
46
- /** Event name — only for 'message' type */
47
- event?: string;
48
- /** Method name on the controller class */
49
- handlerName: string;
50
- }
51
- /**
52
- * Resolved principal returned from {@link WsAuthConfig.resolveUser}. Only `id`
53
- * is required. The whole object is stored on the `WsContext` as `user`, and
54
- * `id` alone as `userId` — read them with `ctx.get('user')` / `ctx.get('userId')`.
55
- */
56
- interface WsAuthenticatedUser {
57
- id: string;
58
- [key: string]: unknown;
59
- }
60
- interface WsAuthConfig {
61
- /**
62
- * Resolve a user from the upgrade request. Called once per socket, before
63
- * any `@OnConnect` handler fires. Return `null` or throw to reject.
64
- *
65
- * A rejected socket is accepted and then closed with code `4401` — a
66
- * WebSocket close code, not an HTTP status. Browsers do not expose a failed
67
- * handshake's status to script, so a close code is what a client can act on.
68
- *
69
- * Messages the client sends before this settles are held and delivered once
70
- * `@OnConnect` has settled, up to 64 messages or 1 MiB; beyond either the socket is
71
- * closed with `1008`. A socket that closes while this runs never reaches
72
- * `@OnConnect` or its user room.
73
- */
74
- resolveUser: (request: IncomingMessage) => Promise<WsAuthenticatedUser | null> | WsAuthenticatedUser | null;
75
- /**
76
- * Join each authenticated socket to `user:<id>` as soon as `resolveUser`
77
- * resolves (default: `true` — pass `false` to opt out). Pairs with
78
- * {@link WsUserBroadcaster}.
79
- */
80
- autoJoinUserRoom?: boolean;
81
- /**
82
- * Room name prefix for per-user broadcasting (default: `'user:'`).
83
- * Must match what `@forinda/kickjs-ws`'s `WsUserBroadcaster` targets.
84
- */
85
- userRoomPrefix?: string;
86
- }
87
- interface WsAdapterOptions {
88
- /** Base path for WebSocket upgrade (default: '/ws') */
89
- path?: string;
90
- /** Heartbeat ping interval in ms (default: 30000). Set to 0 to disable. */
91
- heartbeatInterval?: number;
92
- /** Maximum message payload size in bytes */
93
- maxPayload?: number;
94
- /** Optional authenticated-handshake configuration. */
95
- auth?: WsAuthConfig;
96
- }
97
- /**
98
- * Per-user broadcasting across all WS namespaces. Always registered on the DI
99
- * container. With {@link WsAdapterOptions.auth} sockets join `user:<id>`
100
- * automatically; without it a controller can join the room manually and the
101
- * helper works the same.
102
- *
103
- * Reaches sockets in THIS process only.
104
- */
105
- interface WsUserBroadcaster {
106
- /** Send a single event to every socket bound to this user. */
107
- toUser(userId: string): {
108
- send(event: string, data: unknown): void;
109
- };
110
- /** Convenience — `toUser(id).send(event, data)` in one call. */
111
- broadcastToUser(userId: string, event: string, data: unknown): void;
112
- /** Room name for a given user (respects `userRoomPrefix`). */
113
- roomFor(userId: string): string;
114
- }
115
- /** DI token for the live {@link WsAdapter} instance. */
116
- export declare const WS_ADAPTER: import("@forinda/kickjs").InjectionToken<unknown>;
117
- /** DI token for the shared {@link RoomManager}. */
118
- export declare const WS_ROOM_MANAGER: import("@forinda/kickjs").InjectionToken<RoomManager>;
119
- /** DI token for the per-user broadcaster helper. */
120
- export declare const WS_USER_BROADCASTER: import("@forinda/kickjs").InjectionToken<WsUserBroadcaster>;
121
- //#endregion
122
6
  //#region src/ws-adapter.d.ts
123
7
  /**
124
8
  * Public extension methods exposed by a WsAdapter instance — broadcast
@@ -199,6 +83,8 @@ export declare class WsContext {
199
83
  private readonly namespaceSockets;
200
84
  /** Called with the number of frames each send wrote — feeds `messagesSent`. */
201
85
  private readonly onSend?;
86
+ /** Relays namespace broadcasts to other instances when the adapter has a broker. */
87
+ private readonly onBroadcast?;
202
88
  /** Unique connection ID */
203
89
  readonly id: string;
204
90
  /** Parsed message payload (set for @OnMessage handlers) */
@@ -213,7 +99,9 @@ export declare class WsContext {
213
99
  private metadata;
214
100
  constructor(socket: WebSocket, server: WebSocketServer, roomManager: RoomManager, namespaceSockets: Map<string, WebSocket>, id: string, namespace: string, request: IncomingMessage,
215
101
  /** Called with the number of frames each send wrote — feeds `messagesSent`. */
216
- onSend?: ((count: number) => void) | undefined);
102
+ onSend?: ((count: number) => void) | undefined,
103
+ /** Relays namespace broadcasts to other instances when the adapter has a broker. */
104
+ onBroadcast?: ((namespace: string, event: string, data: any, excludeId?: string) => void) | undefined);
217
105
  /** Parsed cookies from the upgrade request (raw `Cookie` header parse). */
218
106
  get cookies(): Record<string, string>;
219
107
  /** Get a metadata value */
@@ -313,5 +201,5 @@ export declare const OnError: () => MethodDecorator;
313
201
  */
314
202
  export declare function OnMessage(event: string): MethodDecorator;
315
203
  //#endregion
316
- export type { WsAdapterOptions, WsAuthConfig, WsAuthenticatedUser, WsHandlerDefinition, WsHandlerType, WsUserBroadcaster };
204
+ export { RoomManager, WS_ADAPTER, WS_METADATA, WS_ROOM_MANAGER, WS_USER_BROADCASTER, type WsAdapterOptions, type WsAuthConfig, type WsAuthenticatedUser, type WsBroker, type WsBrokerMessage, type WsHandlerDefinition, type WsHandlerType, type WsUserBroadcaster };
317
205
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/room-manager.ts","../src/interfaces.ts","../src/ws-adapter.ts","../src/ws-context.ts","../src/decorators.ts"],"mappings":";;;;;;;;;;;;;;;;;qBAca;mBAEkB;;EAA7B,YAA6B,WAAU;;UAG/B;;UAEA;EAER,KAAK,kBAAkB,QAAQ,WAAW;EAY1C,MAAM,kBAAkB;;EAWxB,SAAS;EAaT,SAAS;EAIT,WAAW,eAAe,YAAY;;EAKtC,eAAe;;EASf,UAAU,cAAc,eAAe,WAAW;;;;qBCpEvC;WACX;WACA;;KAGU;UAEK;EACf,MAAM;;EAEN;;EAEA;;;;;;;UAQe;EACf;GACC;;UAGc;;;;;;;;;;;;;;EAcf,cACE,SAAS,oBACN,QAAQ,8BAA8B;;;;;;EAM3C;;;;;EAKA;;UAGe;;EAEf;;EAEA;;EAEA;;EAEA,OAAO;;;;;;;;;;UAWQ;;EAEf,OAAO;IAAmB,KAAK,eAAe;;;EAE9C,gBAAgB,gBAAgB,eAAe;;EAE/C,QAAQ;;;qBAIG,sCAAU;;qBAEV,2CAAe,eAAA;;qBAEf,+CAAmB,eAAA;;;;;;;;UCnDf;;EAEf;IACE;IACA;IACA;IACA;IACA;IACA,YAAY;MAAiB;MAAqB;;IAClD,OAAO,WAAW;;;EAGpB,SAAS;;EAET,gBAAgB,gBAAgB,eAAe;;WAEtC,kBAAkB;;WAElB,mBAAmB;;WAEnB,kBAAkB;;WAElB,cAAc;;WAEd,UAAU;;;;;;;;;;;;;;;;;;;;;qBAsBR,qCAAS,eAAA,kBAAA;;;;;;;;;;;;;;;;;;;qBCxET;WAgBA,QAAQ;WACR,QAAQ;mBACA;mBACA;;mBAKA;;WAtBV;;EAET;;EAEA;;WAES;;;WAGA,SAAS;UAEV;EAER,YACW,QAAQ,WACR,QAAQ,iBACA,aAAa,aACb,kBAAkB,YAAY,YAC/C,YACA,mBACA,SAAS,iBAEQ;;EAAA,WAAU;;MAUzB,WAAW;;EAef,IAAI,SAAS,cAAc;;EAK3B,IAAI,aAAa;;EAKjB,KAAK,eAAe;;EAQpB,UAAU,eAAe;;EAazB,aAAa,eAAe;;EAa5B,KAAK;;EAKL,MAAM;;EAKN;;EAKA,GAAG;IAAiB,KAAK,eAAe;;;;;;;;;;;;;;;;;;;;;wBC7G1B,aAAa,qBAAqB;;;;;;;;;;;;qBA+BrC,iBAtBA;;;;;;;;;;;;qBAmCA,oBAnCA;;;;;;;;;;;;qBAgDA,eAhDA;;;;;;;;;;;;;;;;;;;;wBAqEG,UAAU,gBAAgB"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/ws-adapter.ts","../src/ws-context.ts","../src/decorators.ts"],"mappings":";;;;;;;;;;;UA+CiB;;EAEf;IACE;IACA;IACA;IACA;IACA;IACA,YAAY;MAAiB;MAAqB;;IAClD,OAAO,WAAW;;;EAGpB,SAAS;;EAET,gBAAgB,gBAAgB,eAAe;;WAEtC,kBAAkB;;WAElB,mBAAmB;;WAEnB,kBAAkB;;WAElB,cAAc;;WAEd,UAAU;;;;;;;;;;;;;;;;;;;;;qBAsBR,qCAAS,eAAA,kBAAA;;;;;;;;;;;;;;;;;;;qBCtDT;WAgBA,QAAQ;WACR,QAAQ;mBACA;mBACA;;mBAKA;;mBAEA;;WAxBV;;EAET;;EAEA;;WAES;;;WAGA,SAAS;UAEV;EAER,YACW,QAAQ,WACR,QAAQ,iBACA,aAAa,aACb,kBAAkB,YAAY,YAC/C,YACA,mBACA,SAAS,iBAEQ;;EAAA,WAAU,qCAEV;;EAAA,gBACf,mBACA,eACA,WACA;;MAWA,WAAW;;EAKf,IAAI,SAAS,cAAc;;EAK3B,IAAI,aAAa;;EAKjB,KAAK,eAAe;;EAQpB,UAAU,eAAe;;EAczB,aAAa,eAAe;;EAc5B,KAAK;;EAKL,MAAM;;EAKN;;EAKA,GAAG;IAAiB,KAAK,eAAe;;;;;;;;;;;;;;;;;;;;;wBC/H1B,aAAa,qBAAqB;;;;;;;;;;;;qBA+BrC,iBAtBA;;;;;;;;;;;;qBAmCA,oBAnCA;;;;;;;;;;;;qBAgDA,eAhDA;;;;;;;;;;;;;;;;;;;;wBAqEG,UAAU,gBAAgB"}
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @forinda/kickjs-ws v7.0.2
2
+ * @forinda/kickjs-ws v7.1.1
3
3
  *
4
4
  * Copyright (c) Felix Orinda
5
5
  *
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * @license MIT
10
10
  */
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{socket;server;roomManager;namespaceSockets;onSend;id;data;event;namespace;request;metadata=new Map;constructor(socket,server,roomManager,namespaceSockets,id,namespace,request,onSend){this.socket=socket,this.server=server,this.roomManager=roomManager,this.namespaceSockets=namespaceSockets,this.onSend=onSend,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})),this.onSend?.(1))}broadcast(event,data){let message=JSON.stringify({event,data}),sent=0;for(let[id,socket]of this.namespaceSockets)id!==this.id&&socket.readyState===socket.OPEN&&(socket.send(message),sent++);sent&&this.onSend?.(sent)}broadcastAll(event,data){let message=JSON.stringify({event,data}),sent=0;for(let[,socket]of this.namespaceSockets)socket.readyState===socket.OPEN&&(socket.send(message),sent++);sent&&this.onSend?.(sent)}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{onSend;constructor(onSend){this.onSend=onSend}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}),sent=0;for(let[id,socket]of sockets)id!==excludeId&&socket.readyState===socket.OPEN&&(socket.send(message),sent++);sent&&this.onSend?.(sent)}};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,heartbeatTimer=null,totalConnections=ref(0),activeConnections=ref(0),messagesReceived=ref(0),messagesSent=ref(0),wsErrors=ref(0),countSent=n=>{messagesSent.value+=n},roomManager=new RoomManager(countSent),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=async(controller,method,ctx)=>{try{await controller[method](ctx)}catch(err){log.error({err},`WS handler error in ${method}`)}},invokeHandlers=async(controller,handlers,type,ctx)=>{await Promise.all(handlers.filter(handler=>handler.type===type).map(handler=>safeInvoke(controller,handler.handlerName,ctx)))},authenticate=async ctx=>{if(!auth)return!0;try{let user=await auth.resolveUser(ctx.request);return ctx.socket.readyState===ctx.socket.OPEN?!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):!1}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,countSent);entry.contexts.set(socketId,ctx);let controller=container.resolve(entry.controllerClass);ws.on(`pong`,()=>{ws.__alive=!0});let handleMessage=raw=>{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)}},ready=!1,pending=[],pendingBytes=0,dropPending=()=>{pending.length=0,pendingBytes=0};ws.on(`message`,raw=>{if(ready)return handleMessage(raw);let size=typeof raw==`string`?Buffer.byteLength(raw):raw.length;if(pending.length>=64||pendingBytes+size>1048576){dropPending(),ws.close(1008,`Too many messages before connect`);return}pending.push(raw),pendingBytes+=size}),(auth?authenticate(ctx):Promise.resolve(!0)).then(async ok=>{if(!ok||ws.readyState!==ws.OPEN||(await invokeHandlers(controller,entry.handlers,`connect`,ctx),ws.readyState!==ws.OPEN))return dropPending();ready=!0;for(let raw of pending.splice(0))handleMessage(raw);pendingBytes=0}),ws.on(`close`,()=>{dropPending(),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){if(server.listenerCount(`upgrade`)>1)return;socket.write(`HTTP/1.1 404 Not Found\r
11
+ import{a as wsControllerRegistry,i as WS_USER_BROADCASTER,n as WS_METADATA,r as WS_ROOM_MANAGER,t as WS_ADAPTER}from"./interfaces-DsBi7cdB.mjs";import{t as WsContext}from"./ws-context-B9Wb2ZWj.mjs";import{randomUUID}from"node:crypto";import{WebSocketServer}from"ws";import{Service,createLogger,defineAdapter,getClassMeta,getClassMetaOrUndefined,pushClassMeta,ref,setClassMeta}from"@forinda/kickjs";var RoomManager=class{onSend;onBroadcast;constructor(onSend,onBroadcast){this.onSend=onSend,this.onBroadcast=onBroadcast}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){this.deliver(room,event,data,excludeId),this.onBroadcast?.(room,event,data,excludeId)}deliver(room,event,data,excludeId){let sockets=this.roomSockets.get(room);if(!sockets)return;let message=JSON.stringify({event,data}),sent=0;for(let[id,socket]of sockets)id!==excludeId&&socket.readyState===socket.OPEN&&(socket.send(message),sent++);sent&&this.onSend?.(sent)}};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,heartbeatTimer=null,detachUpgrade=null,totalConnections=ref(0),activeConnections=ref(0),messagesReceived=ref(0),messagesSent=ref(0),wsErrors=ref(0),countSent=n=>{messagesSent.value+=n},broker=options.broker,origin=randomUUID(),relay=message=>{try{Promise.resolve(broker.publish(message)).catch(err=>log.error({err},`WS broker publish failed`))}catch(err){log.error({err},`WS broker publish failed`)}},relayRoom=broker?(room,event,data,exclude)=>relay({origin,room,event,data,exclude}):void 0,relayNamespace=broker?(namespace,event,data,exclude)=>relay({origin,namespace,event,data,exclude}):void 0,roomManager=new RoomManager(countSent,relayRoom),isBrokerMessage=m=>typeof m==`object`&&!!m&&typeof m.origin==`string`&&typeof m.event==`string`&&(m.exclude===void 0||typeof m.exclude==`string`)&&typeof m.room==`string`!=(typeof m.namespace==`string`)&&(m.room===void 0||typeof m.room==`string`)&&(m.namespace===void 0||typeof m.namespace==`string`),onRelayed=message=>{if(!isBrokerMessage(message)||message.origin===origin)return;let{room,namespace,event,data,exclude}=message;if(room!==void 0)return roomManager.deliver(room,event,data,exclude);let frame=JSON.stringify({event,data}),sent=0;for(let entry of namespaces.values())if(entry.namespace===namespace)for(let[id,socket]of entry.sockets)id!==exclude&&socket.readyState===socket.OPEN&&(socket.send(frame),sent++);sent&&countSent(sent)},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=async(controller,method,ctx)=>{try{await controller[method](ctx)}catch(err){log.error({err},`WS handler error in ${method}`)}},invokeHandlers=async(controller,handlers,type,ctx)=>{await Promise.all(handlers.filter(handler=>handler.type===type).map(handler=>safeInvoke(controller,handler.handlerName,ctx)))},authenticate=async ctx=>{if(!auth)return!0;try{let user=await auth.resolveUser(ctx.request);return ctx.socket.readyState===ctx.socket.OPEN?!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):!1}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,countSent,relayNamespace);entry.contexts.set(socketId,ctx);let controller=container.resolve(entry.controllerClass);ws.on(`pong`,()=>{ws.__alive=!0});let handleMessage=raw=>{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}let messageCtx=Object.create(ctx);messageCtx.event=event,messageCtx.data=data;let handler=entry.handlers.find(h=>h.type===`message`&&h.event===event);if(handler)safeInvoke(controller,handler.handlerName,messageCtx);else{let catchAll=entry.handlers.find(h=>h.type===`message`&&h.event===`*`);catchAll&&safeInvoke(controller,catchAll.handlerName,messageCtx)}}catch{let errorCtx=Object.create(ctx);errorCtx.data={message:`Invalid JSON`},invokeHandlers(controller,entry.handlers,`error`,errorCtx)}},ready=!1,pending=[],pendingBytes=0,dropPending=()=>{pending.length=0,pendingBytes=0};ws.on(`message`,raw=>{if(ready)return handleMessage(raw);let size=typeof raw==`string`?Buffer.byteLength(raw):raw.length;if(pending.length>=64||pendingBytes+size>1048576){dropPending(),ws.close(1008,`Too many messages before connect`);return}pending.push(raw),pendingBytes+=size}),(auth?authenticate(ctx):Promise.resolve(!0)).then(async ok=>{if(!ok||ws.readyState!==ws.OPEN||(await invokeHandlers(controller,entry.handlers,`connect`,ctx),ws.readyState!==ws.OPEN))return dropPending();ready=!0;for(let raw of pending.splice(0))handleMessage(raw);pendingBytes=0}),ws.on(`close`,()=>{dropPending(),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++;let errorCtx=Object.create(ctx);errorCtx.data={message:err.message,name:err.name},invokeHandlers(controller,entry.handlers,`error`,errorCtx)})};return{getStats,userRoom,broadcastToUser,totalConnections,activeConnections,messagesReceived,messagesSent,wsErrors,async 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})`)}broker&&await broker.subscribe(onRelayed)},afterStart({server}){if(!server)return;wss=new WebSocketServer({noServer:!0,maxPayload});let onUpgrade=(request,socket,head)=>{let pathname=(request.url||`/`).split(`?`)[0],entry=namespaces.get(pathname);if(!entry){if(server.listenerCount(`upgrade`)>1)return;socket.write(`HTTP/1.1 404 Not Found\r
12
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};
13
+ `),socket.destroy();return}wss.handleUpgrade(request,socket,head,ws=>{handleConnection(ws,entry,request)})};server.on(`upgrade`,onUpgrade),detachUpgrade=()=>server.off(`upgrade`,onUpgrade),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)`)},async 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()}detachUpgrade?.(),detachUpgrade=null,wss?.close(),await broker?.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};
14
14
  //# sourceMappingURL=index.mjs.map
@@ -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. The whole object is stored on the `WsContext` as `user`, and\n * `id` alone as `userId` — read them with `ctx.get('user')` / `ctx.get('userId')`.\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, before\n * any `@OnConnect` handler fires. Return `null` or throw to reject.\n *\n * A rejected socket is accepted and then closed with code `4401` — a\n * WebSocket close code, not an HTTP status. Browsers do not expose a failed\n * handshake's status to script, so a close code is what a client can act on.\n *\n * Messages the client sends before this settles are held and delivered once\n * `@OnConnect` has settled, up to 64 messages or 1 MiB; beyond either the socket is\n * closed with `1008`. A socket that closes while this runs never reaches\n * `@OnConnect` or its user room.\n */\n resolveUser: (\n request: IncomingMessage,\n ) => Promise<WsAuthenticatedUser | null> | WsAuthenticatedUser | null\n /**\n * Join each authenticated socket to `user:<id>` as soon as `resolveUser`\n * resolves (default: `true` — pass `false` to opt out). Pairs with\n * {@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. Always registered on the DI\n * container. With {@link WsAdapterOptions.auth} sockets join `user:<id>`\n * automatically; without it a controller can join the room manually and the\n * helper works the same.\n *\n * Reaches sockets in THIS process only.\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 /** Called with the number of frames each send wrote — feeds `messagesSent`. */\n private readonly onSend?: (count: number) => void,\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 this.onSend?.(1)\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 let sent = 0\n for (const [id, socket] of this.namespaceSockets) {\n if (id !== this.id && socket.readyState === socket.OPEN) {\n socket.send(message)\n sent++\n }\n }\n if (sent) this.onSend?.(sent)\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 let sent = 0\n for (const [, socket] of this.namespaceSockets) {\n if (socket.readyState === socket.OPEN) {\n socket.send(message)\n sent++\n }\n }\n if (sent) this.onSend?.(sent)\n }\n\n /** Join a room. Names are global across namespaces — see {@link RoomManager}. */\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 *\n * Room names are GLOBAL, not per namespace: a room joined from `/ws/chat` and\n * one joined from `/ws/admin` with the same name are the same room. That is\n * what lets a service broadcast through `WS_ROOM_MANAGER` and lets `user:<id>`\n * reach a user's sockets in every namespace — prefix names (`chat:lobby`) when\n * namespaces must not overlap.\n *\n * Membership lives in this process only. A second instance has its own rooms.\n */\nexport class RoomManager {\n /** @param onSend Called with the number of frames each broadcast wrote. */\n constructor(private readonly onSend?: (count: number) => void) {}\n\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 let sent = 0\n for (const [id, socket] of sockets) {\n if (id !== excludeId && socket.readyState === socket.OPEN) {\n socket.send(message)\n sent++\n }\n }\n if (sent) this.onSend?.(sent)\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\n/** Messages held from a socket whose `auth.resolveUser` has not settled yet. */\nconst MAX_PENDING_BEFORE_AUTH = 64\n/** Total bytes held before auth. `maxPayload` bounds one message, not the queue. */\nconst MAX_PENDING_BYTES_BEFORE_AUTH = 1024 * 1024\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 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 // Every frame written through a context or a room lands here. The counter\n // was declared and exposed (devtools reads it) but never incremented.\n const countSent = (n: number): void => {\n messagesSent.value += n\n }\n const roomManager = new RoomManager(countSent)\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 /** Never rejects: errors are logged. Resolves once an async handler settles. */\n const safeInvoke = async (controller: any, method: string, ctx: WsContext): Promise<void> => {\n try {\n await controller[method](ctx)\n } catch (err) {\n log.error({ err }, `WS handler error in ${method}`)\n }\n }\n\n /** Starts every handler of `type` in order; resolves when all have settled. */\n const invokeHandlers = async (\n controller: any,\n handlers: WsHandlerDefinition[],\n type: WsHandlerDefinition['type'],\n ctx: WsContext,\n ): Promise<void> => {\n await Promise.all(\n handlers\n .filter((handler) => handler.type === type)\n .map((handler) => safeInvoke(controller, handler.handlerName, ctx)),\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 // Joining the user room after close would re-add a socket the close\n // handler already removed from every room.\n if (ctx.socket.readyState !== ctx.socket.OPEN) return false\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 countSent,\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 const handleMessage = (raw: Buffer | string): void => {\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 // A client usually sends as soon as the socket opens, which is before an\n // async `resolveUser` or `@OnConnect` settles — those messages were\n // silently dropped, or reached `@OnMessage` before connect setup ran.\n // They are held and replayed once every `@OnConnect` has settled, so\n // handlers see them in order. The hold is capped: the sender may not be\n // authenticated yet, so an unbounded queue would let anyone buffer memory\n // on the server before being rejected.\n let ready = false\n const pending: Array<Buffer | string> = []\n let pendingBytes = 0\n const dropPending = (): void => {\n pending.length = 0\n pendingBytes = 0\n }\n\n ws.on('message', (raw: Buffer | string) => {\n if (ready) return handleMessage(raw)\n const size = typeof raw === 'string' ? Buffer.byteLength(raw) : raw.length\n if (\n pending.length >= MAX_PENDING_BEFORE_AUTH ||\n pendingBytes + size > MAX_PENDING_BYTES_BEFORE_AUTH\n ) {\n dropPending()\n ws.close(1008, 'Too many messages before connect')\n return\n }\n pending.push(raw)\n pendingBytes += size\n })\n\n const authPromise = auth ? authenticate(ctx) : Promise.resolve(true)\n authPromise.then(async (ok) => {\n // Closed while resolveUser or @OnConnect ran (client left, or the\n // overflow above): going on would act on a dead socket after the close\n // handler already cleaned it up.\n if (!ok || ws.readyState !== ws.OPEN) return dropPending()\n await invokeHandlers(controller, entry.handlers, 'connect', ctx)\n if (ws.readyState !== ws.OPEN) return dropPending()\n ready = true\n for (const raw of pending.splice(0)) handleMessage(raw)\n pendingBytes = 0\n })\n\n ws.on('close', () => {\n dropPending()\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 // Node calls EVERY 'upgrade' listener. Answering 404 here destroyed\n // sockets owned by other listeners on the same server — the\n // devtools bus, a GraphQL subscription server, Vite's HMR socket in\n // dev. Only close the socket when nothing else could be handling it.\n if (server.listenerCount('upgrade') > 1) return\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/room-manager.ts","../src/ws-adapter.ts","../src/decorators.ts"],"sourcesContent":["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 *\n * Room names are GLOBAL, not per namespace: a room joined from `/ws/chat` and\n * one joined from `/ws/admin` with the same name are the same room. That is\n * what lets a service broadcast through `WS_ROOM_MANAGER` and lets `user:<id>`\n * reach a user's sockets in every namespace — prefix names (`chat:lobby`) when\n * namespaces must not overlap.\n *\n * Membership lives in this process only: `getSockets` and `getAllRooms`\n * describe this instance. `broadcast` reaches other instances when the adapter\n * has a broker.\n */\nexport class RoomManager {\n /**\n * @param onSend Called with the number of frames each broadcast wrote.\n * @param onBroadcast Relays each broadcast to other instances.\n */\n constructor(\n private readonly onSend?: (count: number) => void,\n private readonly onBroadcast?: (\n room: string,\n event: string,\n data: any,\n excludeId?: string,\n ) => void,\n ) {}\n\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 — on every instance when a broker is set. */\n broadcast(room: string, event: string, data: any, excludeId?: string): void {\n this.deliver(room, event, data, excludeId)\n this.onBroadcast?.(room, event, data, excludeId)\n }\n\n /** Send to this instance's members of a room only. Relayed broadcasts land here. */\n deliver(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 let sent = 0\n for (const [id, socket] of sockets) {\n if (id !== excludeId && socket.readyState === socket.OPEN) {\n socket.send(message)\n sent++\n }\n }\n if (sent) this.onSend?.(sent)\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 WsBrokerMessage,\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\n/** Messages held from a socket whose `auth.resolveUser` has not settled yet. */\nconst MAX_PENDING_BEFORE_AUTH = 64\n/** Total bytes held before auth. `maxPayload` bounds one message, not the queue. */\nconst MAX_PENDING_BYTES_BEFORE_AUTH = 1024 * 1024\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 let heartbeatTimer: ReturnType<typeof setInterval> | null = null\n /** Removes this adapter's `'upgrade'` listener from the server it attached to. */\n let detachUpgrade: (() => void) | 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 // Every frame written through a context or a room lands here. The counter\n // was declared and exposed (devtools reads it) but never incremented.\n const countSent = (n: number): void => {\n messagesSent.value += n\n }\n\n // Cross-instance fan-out. Every broadcast is delivered to this instance's\n // sockets first, then published under this instance's id; other instances\n // deliver it to theirs and every instance skips its own, so no socket gets\n // a frame twice. Failures are logged, sync or async: a broker outage must\n // not throw out of a handler that only wanted to broadcast.\n //\n // Published synchronously, not deferred: local sockets got `data` as it\n // was at broadcast time, and a deferred publish would serialise whatever\n // the handler mutated it into afterwards.\n const broker = options.broker\n const origin = randomUUID()\n const relay = (message: WsBrokerMessage): void => {\n try {\n Promise.resolve(broker!.publish(message)).catch((err) =>\n log.error({ err }, 'WS broker publish failed'),\n )\n } catch (err) {\n log.error({ err }, 'WS broker publish failed')\n }\n }\n const relayRoom = broker\n ? (room: string, event: string, data: unknown, exclude?: string) =>\n relay({ origin, room, event, data, exclude })\n : undefined\n const relayNamespace = broker\n ? (namespace: string, event: string, data: unknown, exclude?: string) =>\n relay({ origin, namespace, event, data, exclude })\n : undefined\n\n const roomManager = new RoomManager(countSent, relayRoom)\n\n // Whatever arrives on the channel is untrusted in shape — another\n // publisher, a stale version, a `null`. Anything that is not exactly one\n // destination plus string origin/event is dropped rather than thrown on.\n const isBrokerMessage = (m: any): m is WsBrokerMessage =>\n m !== null &&\n typeof m === 'object' &&\n typeof m.origin === 'string' &&\n typeof m.event === 'string' &&\n (m.exclude === undefined || typeof m.exclude === 'string') &&\n (typeof m.room === 'string') !== (typeof m.namespace === 'string') &&\n (m.room === undefined || typeof m.room === 'string') &&\n (m.namespace === undefined || typeof m.namespace === 'string')\n\n const onRelayed = (message: unknown): void => {\n if (!isBrokerMessage(message) || message.origin === origin) return\n const { room, namespace, event, data, exclude } = message\n if (room !== undefined) return roomManager.deliver(room, event, data, exclude)\n const frame = JSON.stringify({ event, data })\n let sent = 0\n for (const entry of namespaces.values()) {\n if (entry.namespace !== namespace) continue\n for (const [id, socket] of entry.sockets) {\n if (id !== exclude && socket.readyState === socket.OPEN) {\n socket.send(frame)\n sent++\n }\n }\n }\n if (sent) countSent(sent)\n }\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 /** Never rejects: errors are logged. Resolves once an async handler settles. */\n const safeInvoke = async (controller: any, method: string, ctx: WsContext): Promise<void> => {\n try {\n await controller[method](ctx)\n } catch (err) {\n log.error({ err }, `WS handler error in ${method}`)\n }\n }\n\n /** Starts every handler of `type` in order; resolves when all have settled. */\n const invokeHandlers = async (\n controller: any,\n handlers: WsHandlerDefinition[],\n type: WsHandlerDefinition['type'],\n ctx: WsContext,\n ): Promise<void> => {\n await Promise.all(\n handlers\n .filter((handler) => handler.type === type)\n .map((handler) => safeInvoke(controller, handler.handlerName, ctx)),\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 // Joining the user room after close would re-add a socket the close\n // handler already removed from every room.\n if (ctx.socket.readyState !== ctx.socket.OPEN) return false\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 countSent,\n relayNamespace,\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 const handleMessage = (raw: Buffer | string): void => {\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 // One context per message, inheriting the socket's (methods, get/set\n // store): a handler suspended on an await must not read the next\n // message's data off a shared object.\n const messageCtx: WsContext = Object.create(ctx)\n messageCtx.event = event\n messageCtx.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, messageCtx)\n } else {\n const catchAll = entry.handlers.find((h) => h.type === 'message' && h.event === '*')\n if (catchAll) {\n safeInvoke(controller, catchAll.handlerName, messageCtx)\n }\n }\n } catch {\n const errorCtx: WsContext = Object.create(ctx)\n errorCtx.data = { message: 'Invalid JSON' }\n invokeHandlers(controller, entry.handlers, 'error', errorCtx)\n }\n }\n\n // A client usually sends as soon as the socket opens, which is before an\n // async `resolveUser` or `@OnConnect` settles — those messages were\n // silently dropped, or reached `@OnMessage` before connect setup ran.\n // They are held and replayed once every `@OnConnect` has settled, so\n // handlers see them in order. The hold is capped: the sender may not be\n // authenticated yet, so an unbounded queue would let anyone buffer memory\n // on the server before being rejected.\n let ready = false\n const pending: Array<Buffer | string> = []\n let pendingBytes = 0\n const dropPending = (): void => {\n pending.length = 0\n pendingBytes = 0\n }\n\n ws.on('message', (raw: Buffer | string) => {\n if (ready) return handleMessage(raw)\n const size = typeof raw === 'string' ? Buffer.byteLength(raw) : raw.length\n if (\n pending.length >= MAX_PENDING_BEFORE_AUTH ||\n pendingBytes + size > MAX_PENDING_BYTES_BEFORE_AUTH\n ) {\n dropPending()\n ws.close(1008, 'Too many messages before connect')\n return\n }\n pending.push(raw)\n pendingBytes += size\n })\n\n const authPromise = auth ? authenticate(ctx) : Promise.resolve(true)\n authPromise.then(async (ok) => {\n // Closed while resolveUser or @OnConnect ran (client left, or the\n // overflow above): going on would act on a dead socket after the close\n // handler already cleaned it up.\n if (!ok || ws.readyState !== ws.OPEN) return dropPending()\n await invokeHandlers(controller, entry.handlers, 'connect', ctx)\n if (ws.readyState !== ws.OPEN) return dropPending()\n ready = true\n for (const raw of pending.splice(0)) handleMessage(raw)\n pendingBytes = 0\n })\n\n ws.on('close', () => {\n dropPending()\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 const errorCtx: WsContext = Object.create(ctx)\n errorCtx.data = { message: err.message, name: err.name }\n invokeHandlers(controller, entry.handlers, 'error', errorCtx)\n })\n }\n\n return {\n getStats,\n userRoom,\n broadcastToUser,\n totalConnections,\n activeConnections,\n messagesReceived,\n messagesSent,\n wsErrors,\n\n async 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 // Subscribed before the server takes connections, so no relayed\n // broadcast is missed between listen and subscribe.\n if (broker) await broker.subscribe(onRelayed)\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. Held by\n // reference so shutdown can remove it: on a dev reload the server\n // outlives this adapter, and a listener left behind ran before the\n // next adapter's and handed its upgrades to this closed\n // WebSocketServer, which answers 503.\n const onUpgrade = (request: IncomingMessage, socket: Duplex, head: Buffer): void => {\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 // Node calls EVERY 'upgrade' listener. Answering 404 here destroyed\n // sockets owned by other listeners on the same server — the\n // devtools bus, a GraphQL subscription server, Vite's HMR socket in\n // dev. Only close the socket when nothing else could be handling it.\n if (server.listenerCount('upgrade') > 1) return\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 server.on('upgrade', onUpgrade)\n detachUpgrade = () => server.off('upgrade', onUpgrade)\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 async 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 detachUpgrade?.()\n detachUpgrade = null\n wss?.close()\n await broker?.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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"}
@@ -0,0 +1,177 @@
1
+
2
+ import { WebSocket } from "ws";
3
+ import { IncomingMessage } from "node:http";
4
+ //#region src/room-manager.d.ts
5
+ /**
6
+ * Manages WebSocket room membership and broadcasting.
7
+ * Standalone from ws/socket.io — can be swapped for socket.io's built-in rooms.
8
+ *
9
+ * Room names are GLOBAL, not per namespace: a room joined from `/ws/chat` and
10
+ * one joined from `/ws/admin` with the same name are the same room. That is
11
+ * what lets a service broadcast through `WS_ROOM_MANAGER` and lets `user:<id>`
12
+ * reach a user's sockets in every namespace — prefix names (`chat:lobby`) when
13
+ * namespaces must not overlap.
14
+ *
15
+ * Membership lives in this process only: `getSockets` and `getAllRooms`
16
+ * describe this instance. `broadcast` reaches other instances when the adapter
17
+ * has a broker.
18
+ */
19
+ declare class RoomManager {
20
+ private readonly onSend?;
21
+ private readonly onBroadcast?;
22
+ /**
23
+ * @param onSend Called with the number of frames each broadcast wrote.
24
+ * @param onBroadcast Relays each broadcast to other instances.
25
+ */
26
+ constructor(onSend?: ((count: number) => void) | undefined, onBroadcast?: ((room: string, event: string, data: any, excludeId?: string) => void) | undefined);
27
+ /** socketId → set of room names */
28
+ private socketRooms;
29
+ /** room name → set of { socketId, socket } */
30
+ private roomSockets;
31
+ join(socketId: string, socket: WebSocket, room: string): void;
32
+ leave(socketId: string, room: string): void;
33
+ /** Remove socket from all rooms (called on disconnect) */
34
+ leaveAll(socketId: string): void;
35
+ getRooms(socketId: string): string[];
36
+ getSockets(room: string): Map<string, WebSocket>;
37
+ /** Get all rooms with their member counts */
38
+ getAllRooms(): Record<string, number>;
39
+ /** Broadcast to all sockets in a room, optionally excluding one — on every instance when a broker is set. */
40
+ broadcast(room: string, event: string, data: any, excludeId?: string): void;
41
+ /** Send to this instance's members of a room only. Relayed broadcasts land here. */
42
+ deliver(room: string, event: string, data: any, excludeId?: string): void;
43
+ }
44
+ //#endregion
45
+ //#region src/interfaces.d.ts
46
+ declare const WS_METADATA: {
47
+ readonly WS_CONTROLLER: 'kick/ws/controller';
48
+ readonly WS_HANDLERS: 'kick/ws/handlers';
49
+ };
50
+ type WsHandlerType = 'connect' | 'disconnect' | 'message' | 'error';
51
+ interface WsHandlerDefinition {
52
+ type: WsHandlerType;
53
+ /** Event name — only for 'message' type */
54
+ event?: string;
55
+ /** Method name on the controller class */
56
+ handlerName: string;
57
+ }
58
+ /**
59
+ * Resolved principal returned from {@link WsAuthConfig.resolveUser}. Only `id`
60
+ * is required. The whole object is stored on the `WsContext` as `user`, and
61
+ * `id` alone as `userId` — read them with `ctx.get('user')` / `ctx.get('userId')`.
62
+ */
63
+ interface WsAuthenticatedUser {
64
+ id: string;
65
+ [key: string]: unknown;
66
+ }
67
+ interface WsAuthConfig {
68
+ /**
69
+ * Resolve a user from the upgrade request. Called once per socket, before
70
+ * any `@OnConnect` handler fires. Return `null` or throw to reject.
71
+ *
72
+ * A rejected socket is accepted and then closed with code `4401` — a
73
+ * WebSocket close code, not an HTTP status. Browsers do not expose a failed
74
+ * handshake's status to script, so a close code is what a client can act on.
75
+ *
76
+ * Messages the client sends before this settles are held and delivered once
77
+ * `@OnConnect` has settled, up to 64 messages or 1 MiB; beyond either the socket is
78
+ * closed with `1008`. A socket that closes while this runs never reaches
79
+ * `@OnConnect` or its user room.
80
+ *
81
+ * `handshakeAuth` is Socket.IO only: the payload of the client's `auth`
82
+ * option (`io(url, { auth: { token } })`) — the usual place for a token,
83
+ * since a browser cannot set headers on a WebSocket. `WsAdapter` omits it.
84
+ */
85
+ resolveUser: (request: IncomingMessage, handshakeAuth?: Record<string, unknown>) => Promise<WsAuthenticatedUser | null> | WsAuthenticatedUser | null;
86
+ /**
87
+ * Join each authenticated socket to `user:<id>` as soon as `resolveUser`
88
+ * resolves (default: `true` — pass `false` to opt out). Pairs with
89
+ * {@link WsUserBroadcaster}.
90
+ */
91
+ autoJoinUserRoom?: boolean;
92
+ /**
93
+ * Room name prefix for per-user broadcasting (default: `'user:'`).
94
+ * Must match what `@forinda/kickjs-ws`'s `WsUserBroadcaster` targets.
95
+ */
96
+ userRoomPrefix?: string;
97
+ }
98
+ interface WsAdapterOptions {
99
+ /** Base path for WebSocket upgrade (default: '/ws') */
100
+ path?: string;
101
+ /** Heartbeat ping interval in ms (default: 30000). Set to 0 to disable. */
102
+ heartbeatInterval?: number;
103
+ /** Maximum message payload size in bytes */
104
+ maxPayload?: number;
105
+ /** Optional authenticated-handshake configuration. */
106
+ auth?: WsAuthConfig;
107
+ /**
108
+ * Relays broadcasts between instances. Without one, rooms, namespace
109
+ * broadcasts and per-user sends reach sockets in this process only.
110
+ * Use `redisBroker` from `@forinda/kickjs-ws/redis`, or implement
111
+ * {@link WsBroker} over any pub/sub.
112
+ */
113
+ broker?: WsBroker;
114
+ }
115
+ /** A broadcast relayed between instances. Exactly one of `room` / `namespace` is set. */
116
+ type WsBrokerMessage = {
117
+ /** Id of the publishing instance — each instance skips its own messages. */
118
+ origin: string;
119
+ event: string;
120
+ data: unknown;
121
+ /** Socket id that must not receive it (the sender of `ctx.broadcast`). */
122
+ exclude?: string;
123
+ } & ({
124
+ /** Room broadcast: `ctx.to(room)`, `WS_ROOM_MANAGER`, per-user sends. */
125
+ room: string;
126
+ namespace?: never;
127
+ } | {
128
+ /** Namespace broadcast: `ctx.broadcast()` / `ctx.broadcastAll()`. */
129
+ namespace: string;
130
+ room?: never;
131
+ });
132
+ /**
133
+ * Cross-instance pub/sub for {@link WsAdapter}. The adapter delivers every
134
+ * broadcast to its own sockets immediately, then publishes it; other
135
+ * instances deliver it to theirs. `data` must be JSON-serialisable — it
136
+ * already is, since it is sent to clients as JSON.
137
+ */
138
+ interface WsBroker {
139
+ /**
140
+ * Called synchronously from the broadcasting handler. Serialise `message`
141
+ * before the first `await`: the handler may mutate `data` afterwards, and
142
+ * local sockets already received the value as it was.
143
+ */
144
+ publish(message: WsBrokerMessage): void | Promise<void>;
145
+ /** Called once from the adapter's `beforeStart`; startup waits for it. */
146
+ subscribe(onMessage: (message: WsBrokerMessage) => void): void | Promise<void>;
147
+ /** Called from the adapter's `shutdown`. Connections the caller passed in stay theirs to close. */
148
+ close?(): void | Promise<void>;
149
+ }
150
+ /**
151
+ * Per-user broadcasting across all WS namespaces. Always registered on the DI
152
+ * container. With {@link WsAdapterOptions.auth} sockets join `user:<id>`
153
+ * automatically; without it a controller can join the room manually and the
154
+ * helper works the same.
155
+ *
156
+ * Reaches sockets in this process only, unless the adapter has a
157
+ * {@link WsAdapterOptions.broker}.
158
+ */
159
+ interface WsUserBroadcaster {
160
+ /** Send a single event to every socket bound to this user. */
161
+ toUser(userId: string): {
162
+ send(event: string, data: unknown): void;
163
+ };
164
+ /** Convenience — `toUser(id).send(event, data)` in one call. */
165
+ broadcastToUser(userId: string, event: string, data: unknown): void;
166
+ /** Room name for a given user (respects `userRoomPrefix`). */
167
+ roomFor(userId: string): string;
168
+ }
169
+ /** DI token for the live {@link WsAdapter} instance. */
170
+ declare const WS_ADAPTER: import("@forinda/kickjs").InjectionToken<unknown>;
171
+ /** DI token for the shared {@link RoomManager}. */
172
+ declare const WS_ROOM_MANAGER: import("@forinda/kickjs").InjectionToken<RoomManager>;
173
+ /** DI token for the per-user broadcaster helper. */
174
+ declare const WS_USER_BROADCASTER: import("@forinda/kickjs").InjectionToken<WsUserBroadcaster>;
175
+ //#endregion
176
+ export { WsAdapterOptions as a, WsBroker as c, WsHandlerType as d, WsUserBroadcaster as f, WS_USER_BROADCASTER as i, WsBrokerMessage as l, WS_METADATA as n, WsAuthConfig as o, RoomManager as p, WS_ROOM_MANAGER as r, WsAuthenticatedUser as s, WS_ADAPTER as t, WsHandlerDefinition as u };
177
+ //# sourceMappingURL=interfaces-Bw3lVCoj.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interfaces-Bw3lVCoj.d.mts","names":[],"sources":["../src/room-manager.ts","../src/interfaces.ts"],"mappings":";;;;;;;;;;;;;;;;;;cAgBa;mBAMQ;mBACA;;;;;EAFnB,YACmB,WAAU,qCACV,gBACf,cACA,eACA,WACA;;UAKI;;UAEA;EAER,KAAK,kBAAkB,QAAQ,WAAW;EAY1C,MAAM,kBAAkB;;EAWxB,SAAS;EAaT,SAAS;EAIT,WAAW,eAAe,YAAY;;EAKtC,eAAe;;EASf,UAAU,cAAc,eAAe,WAAW;;EAMlD,QAAQ,cAAc,eAAe,WAAW;;;;cCvFrC;WACX;WACA;;KAGU;UAEK;EACf,MAAM;;EAEN;;EAEA;;;;;;;UAQe;EACf;GACC;;UAGc;;;;;;;;;;;;;;;;;;EAkBf,cACE,SAAS,iBACT,gBAAgB,4BACb,QAAQ,8BAA8B;;;;;;EAM3C;;;;;EAKA;;UAGe;;EAEf;;EAEA;;EAEA;;EAEA,OAAO;;;;;;;EAOP,SAAS;;;KAIC;;EAEV;EACA;EACA;;EAEA;;;EAII;EACA;;;EAIA;EACA;;;;;;;;UAUW;;;;;;EAMf,QAAQ,SAAS,yBAAyB;;EAE1C,UAAU,YAAY,SAAS,kCAAkC;;EAEjE,iBAAiB;;;;;;;;;;;UAYF;;EAEf,OAAO;IAAmB,KAAK,eAAe;;;EAE9C,gBAAgB,gBAAgB,eAAe;;EAE/C,QAAQ;;;cAIG,sCAAU;;cAEV,2CAAe,eAAA;;cAEf,+CAAmB,eAAA"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @forinda/kickjs-ws v7.1.1
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{createToken}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;export{wsControllerRegistry as a,WS_USER_BROADCASTER as i,WS_METADATA as n,WS_ROOM_MANAGER as r,WS_ADAPTER as t};
12
+ //# sourceMappingURL=interfaces-DsBi7cdB.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interfaces-DsBi7cdB.mjs","names":[],"sources":["../src/interfaces.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. The whole object is stored on the `WsContext` as `user`, and\n * `id` alone as `userId` — read them with `ctx.get('user')` / `ctx.get('userId')`.\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, before\n * any `@OnConnect` handler fires. Return `null` or throw to reject.\n *\n * A rejected socket is accepted and then closed with code `4401` — a\n * WebSocket close code, not an HTTP status. Browsers do not expose a failed\n * handshake's status to script, so a close code is what a client can act on.\n *\n * Messages the client sends before this settles are held and delivered once\n * `@OnConnect` has settled, up to 64 messages or 1 MiB; beyond either the socket is\n * closed with `1008`. A socket that closes while this runs never reaches\n * `@OnConnect` or its user room.\n *\n * `handshakeAuth` is Socket.IO only: the payload of the client's `auth`\n * option (`io(url, { auth: { token } })`) — the usual place for a token,\n * since a browser cannot set headers on a WebSocket. `WsAdapter` omits it.\n */\n resolveUser: (\n request: IncomingMessage,\n handshakeAuth?: Record<string, unknown>,\n ) => Promise<WsAuthenticatedUser | null> | WsAuthenticatedUser | null\n /**\n * Join each authenticated socket to `user:<id>` as soon as `resolveUser`\n * resolves (default: `true` — pass `false` to opt out). Pairs with\n * {@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 * Relays broadcasts between instances. Without one, rooms, namespace\n * broadcasts and per-user sends reach sockets in this process only.\n * Use `redisBroker` from `@forinda/kickjs-ws/redis`, or implement\n * {@link WsBroker} over any pub/sub.\n */\n broker?: WsBroker\n}\n\n/** A broadcast relayed between instances. Exactly one of `room` / `namespace` is set. */\nexport type WsBrokerMessage = {\n /** Id of the publishing instance — each instance skips its own messages. */\n origin: string\n event: string\n data: unknown\n /** Socket id that must not receive it (the sender of `ctx.broadcast`). */\n exclude?: string\n} & (\n | {\n /** Room broadcast: `ctx.to(room)`, `WS_ROOM_MANAGER`, per-user sends. */\n room: string\n namespace?: never\n }\n | {\n /** Namespace broadcast: `ctx.broadcast()` / `ctx.broadcastAll()`. */\n namespace: string\n room?: never\n }\n)\n\n/**\n * Cross-instance pub/sub for {@link WsAdapter}. The adapter delivers every\n * broadcast to its own sockets immediately, then publishes it; other\n * instances deliver it to theirs. `data` must be JSON-serialisable — it\n * already is, since it is sent to clients as JSON.\n */\nexport interface WsBroker {\n /**\n * Called synchronously from the broadcasting handler. Serialise `message`\n * before the first `await`: the handler may mutate `data` afterwards, and\n * local sockets already received the value as it was.\n */\n publish(message: WsBrokerMessage): void | Promise<void>\n /** Called once from the adapter's `beforeStart`; startup waits for it. */\n subscribe(onMessage: (message: WsBrokerMessage) => void): void | Promise<void>\n /** Called from the adapter's `shutdown`. Connections the caller passed in stay theirs to close. */\n close?(): void | Promise<void>\n}\n\n/**\n * Per-user broadcasting across all WS namespaces. Always registered on the DI\n * container. With {@link WsAdapterOptions.auth} sockets join `user:<id>`\n * automatically; without it a controller can join the room manually and the\n * helper works the same.\n *\n * Reaches sockets in this process only, unless the adapter has a\n * {@link WsAdapterOptions.broker}.\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"],"mappings":";;;;;;;;;;yCASA,MAAa,YAAc,CACzB,cAAe,qBACf,YAAa,kBACf,EAsIa,WAAa,YAAqB,iBAAiB,EAEnD,gBAAkB,YAAyB,qBAAqB,EAEhE,oBAAsB,YAA+B,yBAAyB,EAG9E,qBAAuB,IAAI"}
@@ -0,0 +1,29 @@
1
+
2
+ import { c as WsBroker } from "./interfaces-Bw3lVCoj.mjs";
3
+ //#region src/redis.d.ts
4
+ /** The part of a Redis client the broker calls. An ioredis client fits as-is. */
5
+ export interface RedisPubSubClient {
6
+ publish(channel: string, message: string): unknown;
7
+ subscribe(channel: string): unknown;
8
+ unsubscribe(channel: string): unknown;
9
+ on(event: 'message', listener: (channel: string, message: string) => void): unknown;
10
+ off?(event: 'message', listener: (channel: string, message: string) => void): unknown;
11
+ }
12
+ export interface RedisBrokerOptions {
13
+ /** Connection used for `PUBLISH`. Can be shared with the rest of the app. */
14
+ publisher: RedisPubSubClient;
15
+ /**
16
+ * A separate connection — a Redis connection in subscribe mode cannot run
17
+ * other commands. With ioredis, `publisher.duplicate()`.
18
+ */
19
+ subscriber: RedisPubSubClient;
20
+ /** Channel shared by every instance of one app. Default `kickjs:ws`; change it to run two apps on one Redis. */
21
+ channel?: string;
22
+ }
23
+ /**
24
+ * Both connections stay the caller's: `shutdown` unsubscribes but does not
25
+ * quit them, since the publisher is often the app's shared client.
26
+ */
27
+ export declare function redisBroker({ publisher, subscriber, channel }: RedisBrokerOptions): WsBroker;
28
+ //#endregion
29
+ //# sourceMappingURL=redis.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis.d.mts","names":[],"sources":["../src/redis.ts"],"mappings":";;;;iBAmBiB;EACf,QAAQ,iBAAiB;EACzB,UAAU;EACV,YAAY;EACZ,GAAG,kBAAkB,WAAW,iBAAiB;EACjD,KAAK,kBAAkB,WAAW,iBAAiB;;iBAGpC;;EAEf,WAAW;;;;;EAKX,YAAY;;EAEZ;;;;;;wBAOc,cACd,WACA,YACA,WACC,qBAAqB"}
package/dist/redis.mjs ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @forinda/kickjs-ws v7.1.1
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
+ function redisBroker({publisher,subscriber,channel=`kickjs:ws`}){let listener;return{async publish(message){await publisher.publish(channel,JSON.stringify(message))},async subscribe(onMessage){listener=(from,raw)=>{if(from!==channel)return;let message;try{message=JSON.parse(raw)}catch{return}onMessage(message)},subscriber.on(`message`,listener),await subscriber.subscribe(channel)},async close(){listener&&subscriber.off?.(`message`,listener),await subscriber.unsubscribe(channel)}}}export{redisBroker};
12
+ //# sourceMappingURL=redis.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis.mjs","names":[],"sources":["../src/redis.ts"],"sourcesContent":["/**\n * Redis pub/sub broker for {@link WsAdapter} — rooms, namespace broadcasts and\n * per-user sends reach sockets on every instance.\n *\n * ```ts\n * import Redis from 'ioredis'\n * import { getEnv } from '@forinda/kickjs'\n * import { WsAdapter } from '@forinda/kickjs-ws'\n * import { redisBroker } from '@forinda/kickjs-ws/redis'\n *\n * const redis = new Redis(getEnv('REDIS_URL'))\n * WsAdapter({ broker: redisBroker({ publisher: redis, subscriber: redis.duplicate() }) })\n * ```\n *\n * @module @forinda/kickjs-ws/redis\n */\nimport type { WsBroker, WsBrokerMessage } from './interfaces'\n\n/** The part of a Redis client the broker calls. An ioredis client fits as-is. */\nexport interface RedisPubSubClient {\n publish(channel: string, message: string): unknown\n subscribe(channel: string): unknown\n unsubscribe(channel: string): unknown\n on(event: 'message', listener: (channel: string, message: string) => void): unknown\n off?(event: 'message', listener: (channel: string, message: string) => void): unknown\n}\n\nexport interface RedisBrokerOptions {\n /** Connection used for `PUBLISH`. Can be shared with the rest of the app. */\n publisher: RedisPubSubClient\n /**\n * A separate connection — a Redis connection in subscribe mode cannot run\n * other commands. With ioredis, `publisher.duplicate()`.\n */\n subscriber: RedisPubSubClient\n /** Channel shared by every instance of one app. Default `kickjs:ws`; change it to run two apps on one Redis. */\n channel?: string\n}\n\n/**\n * Both connections stay the caller's: `shutdown` unsubscribes but does not\n * quit them, since the publisher is often the app's shared client.\n */\nexport function redisBroker({\n publisher,\n subscriber,\n channel = 'kickjs:ws',\n}: RedisBrokerOptions): WsBroker {\n let listener: ((channel: string, message: string) => void) | undefined\n\n return {\n async publish(message) {\n await publisher.publish(channel, JSON.stringify(message))\n },\n\n async subscribe(onMessage) {\n listener = (from, raw) => {\n if (from !== channel) return\n let message: unknown\n try {\n message = JSON.parse(raw)\n } catch {\n return // not ours — another publisher on the same channel\n }\n // Valid JSON is not a valid message (`null`, `42`, a foreign object).\n // WsAdapter checks the shape before using it, for every broker, so this\n // only passes it on.\n onMessage(message as WsBrokerMessage)\n }\n subscriber.on('message', listener)\n await subscriber.subscribe(channel)\n },\n\n async close() {\n if (listener) subscriber.off?.('message', listener)\n await subscriber.unsubscribe(channel)\n },\n }\n}\n"],"mappings":";;;;;;;;;;AA2CA,SAAgB,YAAY,CAC1B,UACA,WACA,QAAU,aACqB,CAC/B,IAAI,SAEJ,MAAO,CACL,MAAM,QAAQ,QAAS,CACrB,MAAM,UAAU,QAAQ,QAAS,KAAK,UAAU,OAAO,CAAC,CAC1D,EAEA,MAAM,UAAU,UAAW,CACzB,UAAY,KAAM,MAAQ,CACxB,GAAI,OAAS,QAAS,OACtB,IAAI,QACJ,GAAI,CACF,QAAU,KAAK,MAAM,GAAG,CAC1B,MAAQ,CACN,MACF,CAIA,UAAU,OAA0B,CACtC,EACA,WAAW,GAAG,UAAW,QAAQ,EACjC,MAAM,WAAW,UAAU,OAAO,CACpC,EAEA,MAAM,OAAQ,CACR,UAAU,WAAW,MAAM,UAAW,QAAQ,EAClD,MAAM,WAAW,YAAY,OAAO,CACtC,CACF,CACF"}
@@ -0,0 +1,69 @@
1
+
2
+ import { o as WsAuthConfig } from "./interfaces-Bw3lVCoj.mjs";
3
+ import { Server, ServerOptions, Socket } from "socket.io";
4
+ import { IncomingMessage } from "node:http";
5
+ //#region src/socket-io.d.ts
6
+ /** DI token for the Socket.IO `Server`. */
7
+ export declare const SOCKET_IO: import("@forinda/kickjs").InjectionToken<Server<import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, any>>;
8
+ /** Socket.IO server options (`cors`, `path`, `adapter`, …) plus the shared auth hook. */
9
+ export interface SocketIoAdapterOptions extends Partial<ServerOptions> {
10
+ /**
11
+ * Same contract as {@link WsAdapterOptions.auth}, run as namespace
12
+ * middleware before the connection is accepted. A rejection reaches the
13
+ * client as a `connect_error` whose message is `Unauthorized`.
14
+ */
15
+ auth?: WsAuthConfig;
16
+ }
17
+ /**
18
+ * What `@WsController` handlers receive under {@link SocketIoAdapter}. Mirrors
19
+ * `WsContext`, so a controller written against one runs on the other — with
20
+ * one difference: Socket.IO rooms belong to a namespace, where `ws` rooms are
21
+ * shared across namespaces.
22
+ */
23
+ export declare class SocketIoContext {
24
+ readonly socket: Socket;
25
+ readonly server: Server;
26
+ /** The `@WsController` namespace, e.g. `/chat`. */
27
+ readonly namespace: string;
28
+ /** Payload of the current event (set for `@OnMessage` handlers). */
29
+ data: any;
30
+ /** Name of the current event (set for `@OnMessage` handlers). */
31
+ event: string;
32
+ private metadata;
33
+ constructor(socket: Socket, server: Server,
34
+ /** The `@WsController` namespace, e.g. `/chat`. */
35
+ namespace: string);
36
+ get id(): string;
37
+ /** The handshake request — cookies, headers, query, client IP. */
38
+ get request(): IncomingMessage;
39
+ get cookies(): Record<string, string>;
40
+ get<T = any>(key: string): T | undefined;
41
+ set(key: string, value: any): void;
42
+ /** Emit to this socket. */
43
+ send(event: string, data: any): void;
44
+ /** Emit to every socket in the namespace except this one. */
45
+ broadcast(event: string, data: any): void;
46
+ /** Emit to every socket in the namespace, this one included. */
47
+ broadcastAll(event: string, data: any): void;
48
+ /** Join a room in this namespace. Returns the adapter's promise when its join is async. */
49
+ join(room: string): void | Promise<void>;
50
+ leave(room: string): void | Promise<void>;
51
+ /** Rooms this socket joined, without Socket.IO's own per-socket room. */
52
+ rooms(): string[];
53
+ /** Emit to every socket in a room of this namespace, this one included. */
54
+ to(room: string): {
55
+ send(event: string, data: any): void;
56
+ };
57
+ }
58
+ /**
59
+ * Serves `@WsController` classes over Socket.IO. Namespaces map one-to-one
60
+ * (`@WsController('/chat')` → `io.of('/chat')`), `@OnMessage('send')` handles
61
+ * the client's `socket.emit('send', data)`, and `@OnMessage('*')` catches events
62
+ * no other handler claims.
63
+ *
64
+ * Registers {@link SOCKET_IO} and `WS_USER_BROADCASTER`. Not `WS_ROOM_MANAGER`:
65
+ * Socket.IO keeps its own rooms — reach them through `SOCKET_IO`.
66
+ */
67
+ export declare const SocketIoAdapter: import("@forinda/kickjs").AdapterFactory<SocketIoAdapterOptions, unknown>;
68
+ //#endregion
69
+ //# sourceMappingURL=socket-io.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"socket-io.d.mts","names":[],"sources":["../src/socket-io.ts"],"mappings":";;;;;;qBAyCa,qCAAS,eAAA,2BAAA,sCAAA,sCAAA;;iBAGL,+BAA+B,QAAQ;;;;;;EAMtD,OAAO;;;;;;;;qBASI;WASA,QAAQ;WACR,QAAQ;;WAER;;EAVX;;EAEA;UAEQ;EAER,YACW,QAAQ,QACR,QAAQ,QAER;;EAAA;MAGP;;MAKA,WAAW;MAIX,WAAW;EAIf,IAAI,SAAS,cAAc;EAI3B,IAAI,aAAa;;EAKjB,KAAK,eAAe;;EAKpB,UAAU,eAAe;;EAKzB,aAAa,eAAe;;EAK5B,KAAK,sBAAsB;EAI3B,MAAM,sBAAsB;;EAK5B;;EAKA,GAAG;IAAiB,KAAK,eAAe;;;;;;;;;;;;qBAkB7B,2CAAe,eAAA"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @forinda/kickjs-ws v7.1.1
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{a as wsControllerRegistry,i as WS_USER_BROADCASTER,n as WS_METADATA}from"./interfaces-DsBi7cdB.mjs";import{n as parseCookies}from"./ws-context-B9Wb2ZWj.mjs";import{createLogger,createToken,defineAdapter,getClassMeta,getClassMetaOrUndefined}from"@forinda/kickjs";import{Server}from"socket.io";const log=createLogger(`SocketIoAdapter`),SOCKET_IO=createToken(`kick/ws/SocketIo`);var SocketIoContext=class{socket;server;namespace;data=null;event=``;metadata=new Map;constructor(socket,server,namespace){this.socket=socket,this.server=server,this.namespace=namespace}get id(){return this.socket.id}get request(){return this.socket.request}get cookies(){return parseCookies(this.request.headers.cookie)}get(key){return this.metadata.get(key)}set(key,value){this.metadata.set(key,value)}send(event,data){this.socket.emit(event,data)}broadcast(event,data){this.socket.broadcast.emit(event,data)}broadcastAll(event,data){this.socket.nsp.emit(event,data)}join(room){return this.socket.join(room)}leave(room){return this.socket.leave(room)}rooms(){return[...this.socket.rooms].filter(room=>room!==this.socket.id)}to(room){return{send:(event,data)=>{this.socket.nsp.to(room).emit(event,data)}}}};const SocketIoAdapter=defineAdapter({name:`SocketIoAdapter`,build:({auth,...serverOptions})=>{let io=new Server(serverOptions),detachFromServer=null,namespaces=[],userRoomPrefix=auth?.userRoomPrefix??`user:`,userRoom=userId=>userRoomPrefix+userId,invoke=async(controller,method,ctx)=>{try{await controller[method](ctx)}catch(err){log.error({err},`Socket.IO handler error in ${method}`)}},invokeAll=(controller,handlers,type,ctx)=>Promise.all(handlers.filter(h=>h.type===type).map(h=>invoke(controller,h.handlerName,ctx))),userBroadcaster={roomFor:userRoom,broadcastToUser:(userId,event,data)=>{for(let nsp of namespaces)nsp.to(userRoom(userId)).emit(event,data)},toUser:userId=>({send:(event,data)=>userBroadcaster.broadcastToUser(userId,event,data)})},handleConnection=(socket,namespace,handlers,controller)=>{let ctx=new SocketIoContext(socket,io,namespace),user=socket.data.user,userRoomJoined;user&&(ctx.set(`user`,user),ctx.set(`userId`,user.id),auth?.autoJoinUserRoom!==!1&&(userRoomJoined=ctx.join(userRoom(user.id))));let forEvent=(event,data)=>{let eventCtx=Object.create(ctx);return eventCtx.event=event,eventCtx.data=data,eventCtx},dispatch=(event,data)=>{let handler=handlers.find(h=>h.type===`message`&&h.event===event)??handlers.find(h=>h.type===`message`&&h.event===`*`);handler&&invoke(controller,handler.handlerName,forEvent(event,data))},ready=!1,pending=[],pendingBytes=0;socket.onAny((event,data)=>{if(ready)return dispatch(event,data);let size=Buffer.byteLength(JSON.stringify([event,data]));if(pending.length>=64||pendingBytes+size>1048576){pending.length=0,pendingBytes=0,socket.disconnect(!0);return}pending.push([event,data]),pendingBytes+=size}),socket.on(`disconnect`,()=>{pending.length=0,pendingBytes=0,invokeAll(controller,handlers,`disconnect`,ctx)}),socket.on(`error`,err=>{let errorCtx=forEvent(ctx.event,{message:err.message,name:err.name});invokeAll(controller,handlers,`error`,errorCtx)}),Promise.resolve(userRoomJoined).then(()=>invokeAll(controller,handlers,`connect`,ctx)).then(()=>{if(socket.connected){ready=!0,pendingBytes=0;for(let[event,data]of pending.splice(0))dispatch(event,data)}}).catch(err=>{log.error({err},`Socket.IO user-room join failed; disconnecting`),pending.length=0,pendingBytes=0,socket.disconnect(!0)})};return{beforeStart({container}){container.registerInstance(SOCKET_IO,io),container.registerInstance(WS_USER_BROADCASTER,userBroadcaster);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,[]),nsp=io.of(namespace);namespaces.push(nsp),auth&&nsp.use(async(socket,next)=>{try{let resolved=await auth.resolveUser(socket.request,socket.handshake.auth);if(!resolved?.id)return next(Error(`Unauthorized`));socket.data.user=resolved,next()}catch{next(Error(`Unauthorized`))}}),nsp.on(`connection`,socket=>handleConnection(socket,namespace,handlers,container.resolve(controllerClass))),log.info(`Registered Socket.IO namespace: ${namespace} (${controllerClass.name})`)}},afterStart({server}){if(!server)return;let events=[`request`,`upgrade`,`close`,`listening`],before=new Map(events.map(event=>[event,server.listeners(event)]));io.attach(server);let changes=events.map(event=>{let prior=before.get(event),after=server.listeners(event);return{event,added:after.filter(listener=>!prior.includes(listener)),displaced:prior.filter(listener=>!after.includes(listener))}});detachFromServer=()=>{for(let{event,added,displaced}of changes){for(let listener of added)server.off(event,listener);for(let listener of displaced)server.listeners(event).includes(listener)||server.on(event,listener)}}},async shutdown(){await Promise.allSettled(namespaces.map(async nsp=>{nsp.disconnectSockets(!0),await nsp.adapter.close()})),io.engine?.close(),detachFromServer?.(),detachFromServer=null}}}});export{SOCKET_IO,SocketIoAdapter,SocketIoContext};
12
+ //# sourceMappingURL=socket-io.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"socket-io.mjs","names":[],"sources":["../src/socket-io.ts"],"sourcesContent":["/**\n * Socket.IO transport for the same `@WsController` classes {@link WsAdapter}\n * serves. Pick it for what Socket.IO brings — client reconnection, long-polling\n * fallback, acknowledgements — and scale it with Socket.IO's own adapters\n * (`@socket.io/redis-adapter`) rather than a {@link WsBroker}.\n *\n * ```ts\n * import { SocketIoAdapter } from '@forinda/kickjs-ws/socket.io'\n *\n * bootstrap({ modules, adapters: [SocketIoAdapter({ cors: { origin: 'https://app.example.com' } })] })\n * ```\n *\n * @module @forinda/kickjs-ws/socket.io\n */\nimport type { IncomingMessage } from 'node:http'\nimport { Server, type Namespace, type ServerOptions, type Socket } from 'socket.io'\nimport {\n createLogger,\n createToken,\n defineAdapter,\n getClassMeta,\n getClassMetaOrUndefined,\n} from '@forinda/kickjs'\nimport {\n WS_METADATA,\n WS_USER_BROADCASTER,\n wsControllerRegistry,\n type WsAuthConfig,\n type WsHandlerDefinition,\n type WsUserBroadcaster,\n} from './interfaces'\nimport { parseCookies } from './ws-context'\n\nconst log = createLogger('SocketIoAdapter')\n\n/** Events held from a socket whose async `@OnConnect` has not settled. */\nconst MAX_PENDING_BEFORE_CONNECT = 64\n/** Total payload held meanwhile, measured as JSON. */\nconst MAX_PENDING_BYTES_BEFORE_CONNECT = 1024 * 1024\n\n/** DI token for the Socket.IO `Server`. */\nexport const SOCKET_IO = createToken<Server>('kick/ws/SocketIo')\n\n/** Socket.IO server options (`cors`, `path`, `adapter`, …) plus the shared auth hook. */\nexport interface SocketIoAdapterOptions extends Partial<ServerOptions> {\n /**\n * Same contract as {@link WsAdapterOptions.auth}, run as namespace\n * middleware before the connection is accepted. A rejection reaches the\n * client as a `connect_error` whose message is `Unauthorized`.\n */\n auth?: WsAuthConfig\n}\n\n/**\n * What `@WsController` handlers receive under {@link SocketIoAdapter}. Mirrors\n * `WsContext`, so a controller written against one runs on the other — with\n * one difference: Socket.IO rooms belong to a namespace, where `ws` rooms are\n * shared across namespaces.\n */\nexport class SocketIoContext {\n /** Payload of the current event (set for `@OnMessage` handlers). */\n data: any = null\n /** Name of the current event (set for `@OnMessage` handlers). */\n event = ''\n\n private metadata = new Map<string, any>()\n\n constructor(\n readonly socket: Socket,\n readonly server: Server,\n /** The `@WsController` namespace, e.g. `/chat`. */\n readonly namespace: string,\n ) {}\n\n get id(): string {\n return this.socket.id\n }\n\n /** The handshake request — cookies, headers, query, client IP. */\n get request(): IncomingMessage {\n return this.socket.request\n }\n\n get cookies(): Record<string, string> {\n return parseCookies(this.request.headers.cookie)\n }\n\n get<T = any>(key: string): T | undefined {\n return this.metadata.get(key)\n }\n\n set(key: string, value: any): void {\n this.metadata.set(key, value)\n }\n\n /** Emit to this socket. */\n send(event: string, data: any): void {\n this.socket.emit(event, data)\n }\n\n /** Emit to every socket in the namespace except this one. */\n broadcast(event: string, data: any): void {\n this.socket.broadcast.emit(event, data)\n }\n\n /** Emit to every socket in the namespace, this one included. */\n broadcastAll(event: string, data: any): void {\n this.socket.nsp.emit(event, data)\n }\n\n /** Join a room in this namespace. Returns the adapter's promise when its join is async. */\n join(room: string): void | Promise<void> {\n return this.socket.join(room)\n }\n\n leave(room: string): void | Promise<void> {\n return this.socket.leave(room)\n }\n\n /** Rooms this socket joined, without Socket.IO's own per-socket room. */\n rooms(): string[] {\n return [...this.socket.rooms].filter((room) => room !== this.socket.id)\n }\n\n /** Emit to every socket in a room of this namespace, this one included. */\n to(room: string): { send(event: string, data: any): void } {\n return {\n send: (event: string, data: any) => {\n this.socket.nsp.to(room).emit(event, data)\n },\n }\n }\n}\n\n/**\n * Serves `@WsController` classes over Socket.IO. Namespaces map one-to-one\n * (`@WsController('/chat')` → `io.of('/chat')`), `@OnMessage('send')` handles\n * the client's `socket.emit('send', data)`, and `@OnMessage('*')` catches events\n * no other handler claims.\n *\n * Registers {@link SOCKET_IO} and `WS_USER_BROADCASTER`. Not `WS_ROOM_MANAGER`:\n * Socket.IO keeps its own rooms — reach them through `SOCKET_IO`.\n */\nexport const SocketIoAdapter = defineAdapter<SocketIoAdapterOptions>({\n name: 'SocketIoAdapter',\n build: ({ auth, ...serverOptions }) => {\n const io = new Server(serverOptions)\n /** Undoes what `io.attach()` did to the HTTP server — see afterStart. */\n let detachFromServer: (() => void) | null = null\n const namespaces: Namespace[] = []\n const userRoomPrefix = auth?.userRoomPrefix ?? 'user:'\n const userRoom = (userId: string): string => userRoomPrefix + userId\n\n const invoke = async (controller: any, method: string, ctx: SocketIoContext): Promise<void> => {\n try {\n await controller[method](ctx)\n } catch (err) {\n log.error({ err }, `Socket.IO handler error in ${method}`)\n }\n }\n const invokeAll = (\n controller: any,\n handlers: WsHandlerDefinition[],\n type: WsHandlerDefinition['type'],\n ctx: SocketIoContext,\n ): Promise<unknown> =>\n Promise.all(\n handlers.filter((h) => h.type === type).map((h) => invoke(controller, h.handlerName, ctx)),\n )\n\n // Socket.IO rooms are per namespace, so a user's sockets sit in `user:<id>`\n // once per namespace they connected to. Emitting through each namespace\n // also crosses instances when a Socket.IO adapter is configured.\n const userBroadcaster: WsUserBroadcaster = {\n roomFor: userRoom,\n broadcastToUser: (userId, event, data) => {\n for (const nsp of namespaces) nsp.to(userRoom(userId)).emit(event, data)\n },\n toUser: (userId) => ({\n send: (event, data) => userBroadcaster.broadcastToUser(userId, event, data),\n }),\n }\n\n const handleConnection = (\n socket: Socket,\n namespace: string,\n handlers: WsHandlerDefinition[],\n controller: any,\n ): void => {\n const ctx = new SocketIoContext(socket, io, namespace)\n const user = socket.data.user\n let userRoomJoined: void | Promise<void> = undefined\n if (user) {\n ctx.set('user', user)\n ctx.set('userId', user.id)\n if (auth?.autoJoinUserRoom !== false) userRoomJoined = ctx.join(userRoom(user.id))\n }\n\n // One context per event, inheriting the socket's (methods, get/set\n // store): a handler suspended on an await must not read the next event's\n // data off a shared object.\n const forEvent = (event: string, data: unknown): SocketIoContext => {\n const eventCtx: SocketIoContext = Object.create(ctx)\n eventCtx.event = event\n eventCtx.data = data\n return eventCtx\n }\n\n const dispatch = (event: string, data: unknown): void => {\n const handler =\n handlers.find((h) => h.type === 'message' && h.event === event) ??\n handlers.find((h) => h.type === 'message' && h.event === '*')\n if (!handler) return\n void invoke(controller, handler.handlerName, forEvent(event, data))\n }\n\n // Listeners go on now — Socket.IO drops events nobody listens for — but\n // events are held until every @OnConnect settles, so handlers never see a\n // socket whose connect setup is unfinished. Same limits as WsAdapter;\n // payloads arrive parsed, so their size is measured as JSON.\n let ready = false\n const pending: Array<[string, unknown]> = []\n let pendingBytes = 0\n socket.onAny((event: string, data: unknown) => {\n if (ready) return dispatch(event, data)\n // The event name is client-controlled and held too, so it counts.\n const size = Buffer.byteLength(JSON.stringify([event, data]))\n if (\n pending.length >= MAX_PENDING_BEFORE_CONNECT ||\n pendingBytes + size > MAX_PENDING_BYTES_BEFORE_CONNECT\n ) {\n pending.length = 0\n pendingBytes = 0\n socket.disconnect(true)\n return\n }\n pending.push([event, data])\n pendingBytes += size\n })\n\n socket.on('disconnect', () => {\n pending.length = 0\n pendingBytes = 0\n void invokeAll(controller, handlers, 'disconnect', ctx)\n })\n socket.on('error', (err: Error) => {\n const errorCtx = forEvent(ctx.event, { message: err.message, name: err.name })\n void invokeAll(controller, handlers, 'error', errorCtx)\n })\n\n // The user-room join can be async with some Socket.IO adapters; @OnConnect\n // must not emit to `user:<id>` before this socket is in it.\n void Promise.resolve(userRoomJoined)\n .then(() => invokeAll(controller, handlers, 'connect', ctx))\n .then(() => {\n if (!socket.connected) return\n ready = true\n pendingBytes = 0\n for (const [event, data] of pending.splice(0)) dispatch(event, data)\n })\n // A rejected join would otherwise leave the socket connected with its\n // events held forever. Handlers never reject (errors are logged).\n .catch((err) => {\n log.error({ err }, 'Socket.IO user-room join failed; disconnecting')\n pending.length = 0\n pendingBytes = 0\n socket.disconnect(true)\n })\n }\n\n return {\n beforeStart({ container }) {\n container.registerInstance(SOCKET_IO, io)\n container.registerInstance(WS_USER_BROADCASTER, userBroadcaster)\n\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 const handlers = getClassMeta<WsHandlerDefinition[]>(\n WS_METADATA.WS_HANDLERS,\n controllerClass,\n [],\n )\n\n const nsp = io.of(namespace)\n namespaces.push(nsp)\n\n if (auth) {\n nsp.use(async (socket, next) => {\n try {\n const resolved = await auth.resolveUser(socket.request, socket.handshake.auth)\n if (!resolved?.id) return next(new Error('Unauthorized'))\n socket.data.user = resolved\n next()\n } catch {\n next(new Error('Unauthorized'))\n }\n })\n }\n\n nsp.on('connection', (socket) =>\n handleConnection(socket, namespace, handlers, container.resolve(controllerClass)),\n )\n log.info(`Registered Socket.IO namespace: ${namespace} (${controllerClass.name})`)\n }\n },\n\n afterStart({ server }) {\n if (!server) return\n // engine.io's attach() adds upgrade / close / listening listeners and\n // swaps the server's own request listeners for a wrapper around them.\n // On a dev reload the server outlives this adapter, so shutdown undoes\n // exactly that — otherwise the next adapter shares the server with a\n // dead engine, and the app's request handler (Vite's, in dev) stays\n // wrapped by it.\n const events = ['request', 'upgrade', 'close', 'listening'] as const\n const before = new Map(events.map((event) => [event, server.listeners(event)]))\n io.attach(server)\n // The delta is taken now, not at shutdown: a listener the app or another\n // adapter adds later is not attach()'s, and removing it would take a\n // live handler off the shared server.\n const changes = events.map((event) => {\n const prior = before.get(event)!\n const after = server.listeners(event)\n return {\n event,\n added: after.filter((listener) => !prior.includes(listener)),\n displaced: prior.filter((listener) => !after.includes(listener)),\n }\n })\n detachFromServer = () => {\n for (const { event, added, displaced } of changes) {\n for (const listener of added) server.off(event, listener as (...args: any[]) => void)\n for (const listener of displaced) {\n if (!server.listeners(event).includes(listener)) {\n server.on(event, listener as (...args: any[]) => void)\n }\n }\n }\n }\n },\n\n async shutdown() {\n // Not io.close(): it also closes the HTTP server, which KickJS owns.\n await Promise.allSettled(\n namespaces.map(async (nsp) => {\n nsp.disconnectSockets(true)\n await nsp.adapter.close()\n }),\n )\n io.engine?.close()\n detachFromServer?.()\n detachFromServer = null\n },\n }\n },\n})\n"],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @forinda/kickjs-ws v7.1.1
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
+ function parseCookies(header){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();if(k)try{out[k]=decodeURIComponent(v)}catch{out[k]=v}}return out}var WsContext=class{socket;server;roomManager;namespaceSockets;onSend;onBroadcast;id;data;event;namespace;request;metadata=new Map;constructor(socket,server,roomManager,namespaceSockets,id,namespace,request,onSend,onBroadcast){this.socket=socket,this.server=server,this.roomManager=roomManager,this.namespaceSockets=namespaceSockets,this.onSend=onSend,this.onBroadcast=onBroadcast,this.id=id,this.namespace=namespace,this.request=request,this.data=null,this.event=``}get cookies(){return parseCookies(this.request.headers.cookie)}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})),this.onSend?.(1))}broadcast(event,data){let message=JSON.stringify({event,data}),sent=0;for(let[id,socket]of this.namespaceSockets)id!==this.id&&socket.readyState===socket.OPEN&&(socket.send(message),sent++);sent&&this.onSend?.(sent),this.onBroadcast?.(this.namespace,event,data,this.id)}broadcastAll(event,data){let message=JSON.stringify({event,data}),sent=0;for(let[,socket]of this.namespaceSockets)socket.readyState===socket.OPEN&&(socket.send(message),sent++);sent&&this.onSend?.(sent),this.onBroadcast?.(this.namespace,event,data)}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)}}}};export{parseCookies as n,WsContext as t};
12
+ //# sourceMappingURL=ws-context-B9Wb2ZWj.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ws-context-B9Wb2ZWj.mjs","names":[],"sources":["../src/ws-context.ts"],"sourcesContent":["import type { IncomingMessage } from 'node:http'\nimport type { WebSocket, WebSocketServer } from 'ws'\nimport type { RoomManager } from './room-manager'\n\n/** Raw `Cookie` header parse, shared by the ws and Socket.IO contexts. */\nexport function parseCookies(header: string | undefined): Record<string, string> {\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) continue\n try {\n out[k] = decodeURIComponent(v)\n } catch {\n out[k] = v // malformed %-encoding: keep it raw rather than fail the handler\n }\n }\n return out\n}\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 /** Called with the number of frames each send wrote — feeds `messagesSent`. */\n private readonly onSend?: (count: number) => void,\n /** Relays namespace broadcasts to other instances when the adapter has a broker. */\n private readonly onBroadcast?: (\n namespace: string,\n event: string,\n data: any,\n excludeId?: string,\n ) => void,\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 return parseCookies(this.request.headers.cookie)\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 this.onSend?.(1)\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 let sent = 0\n for (const [id, socket] of this.namespaceSockets) {\n if (id !== this.id && socket.readyState === socket.OPEN) {\n socket.send(message)\n sent++\n }\n }\n if (sent) this.onSend?.(sent)\n this.onBroadcast?.(this.namespace, event, data, this.id)\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 let sent = 0\n for (const [, socket] of this.namespaceSockets) {\n if (socket.readyState === socket.OPEN) {\n socket.send(message)\n sent++\n }\n }\n if (sent) this.onSend?.(sent)\n this.onBroadcast?.(this.namespace, event, data)\n }\n\n /** Join a room. Names are global across namespaces — see {@link RoomManager}. */\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"],"mappings":";;;;;;;;;;AAKA,SAAgB,aAAa,OAAoD,CAC/E,GAAI,CAAC,OAAQ,MAAO,CAAC,EACrB,IAAM,IAA8B,CAAC,EACrC,IAAK,IAAM,QAAQ,OAAO,MAAM,GAAG,EAAG,CACpC,IAAM,IAAM,KAAK,QAAQ,GAAG,EAC5B,GAAI,MAAQ,GAAI,SAChB,IAAM,EAAI,KAAK,MAAM,EAAG,GAAG,CAAC,CAAC,KAAK,EAC5B,EAAI,KAAK,MAAM,IAAM,CAAC,CAAC,CAAC,KAAK,EAC9B,KACL,GAAI,CACF,IAAI,GAAK,mBAAmB,CAAC,CAC/B,MAAQ,CACN,IAAI,GAAK,CACX,CACF,CACA,OAAO,GACT,CAkBA,IAAa,UAAb,KAAuB,CAgBV,OACA,OACQ,YACA,iBAKA,OAEA,YAxBnB,GAEA,KAEA,MAEA,UAGA,QAEA,SAAmB,IAAI,IAEvB,YACE,OACA,OACA,YACA,iBACA,GACA,UACA,QAEA,OAEA,YAMA,CAhBS,KAAA,OAAA,OACA,KAAA,OAAA,OACQ,KAAA,YAAA,YACA,KAAA,iBAAA,iBAKA,KAAA,OAAA,OAEA,KAAA,YAAA,YAOjB,KAAK,GAAK,GACV,KAAK,UAAY,UACjB,KAAK,QAAU,QACf,KAAK,KAAO,KACZ,KAAK,MAAQ,EACf,CAGA,IAAI,SAAkC,CACpC,OAAO,aAAa,KAAK,QAAQ,QAAQ,MAAM,CACjD,CAGA,IAAa,IAA4B,CACvC,OAAO,KAAK,SAAS,IAAI,GAAG,CAC9B,CAGA,IAAI,IAAa,MAAkB,CACjC,KAAK,SAAS,IAAI,IAAK,KAAK,CAC9B,CAGA,KAAK,MAAe,KAAiB,CAC/B,KAAK,OAAO,aAAe,KAAK,OAAO,OACzC,KAAK,OAAO,KAAK,KAAK,UAAU,CAAE,MAAO,IAAK,CAAC,CAAC,EAChD,KAAK,SAAS,CAAC,EAEnB,CAGA,UAAU,MAAe,KAAiB,CACxC,IAAM,QAAU,KAAK,UAAU,CAAE,MAAO,IAAK,CAAC,EAC1C,KAAO,EACX,IAAK,GAAM,CAAC,GAAI,UAAW,KAAK,iBAC1B,KAAO,KAAK,IAAM,OAAO,aAAe,OAAO,OACjD,OAAO,KAAK,OAAO,EACnB,QAGA,MAAM,KAAK,SAAS,IAAI,EAC5B,KAAK,cAAc,KAAK,UAAW,MAAO,KAAM,KAAK,EAAE,CACzD,CAGA,aAAa,MAAe,KAAiB,CAC3C,IAAM,QAAU,KAAK,UAAU,CAAE,MAAO,IAAK,CAAC,EAC1C,KAAO,EACX,IAAK,GAAM,EAAG,UAAW,KAAK,iBACxB,OAAO,aAAe,OAAO,OAC/B,OAAO,KAAK,OAAO,EACnB,QAGA,MAAM,KAAK,SAAS,IAAI,EAC5B,KAAK,cAAc,KAAK,UAAW,MAAO,IAAI,CAChD,CAGA,KAAK,KAAoB,CACvB,KAAK,YAAY,KAAK,KAAK,GAAI,KAAK,OAAQ,IAAI,CAClD,CAGA,MAAM,KAAoB,CACxB,KAAK,YAAY,MAAM,KAAK,GAAI,IAAI,CACtC,CAGA,OAAkB,CAChB,OAAO,KAAK,YAAY,SAAS,KAAK,EAAE,CAC1C,CAGA,GAAG,KAAwD,CACzD,MAAO,CACL,MAAO,MAAe,OAAc,CAClC,KAAK,YAAY,UAAU,KAAM,MAAO,IAAI,CAC9C,CACF,CACF,CACF"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@forinda/kickjs-ws",
3
- "version": "7.0.2",
4
- "description": "WebSocket support with decorators, namespaces, rooms, and DI integration for KickJS",
3
+ "version": "7.1.1",
4
+ "description": "Realtime for KickJS: decorator-driven WebSocket controllers on ws or Socket.IO, a Redis broker for multiple instances, and a Centrifugo integration",
5
5
  "keywords": [
6
6
  "kickjs",
7
7
  "nodejs",
@@ -9,6 +9,10 @@
9
9
  "decorator-driven",
10
10
  "websocket",
11
11
  "ws",
12
+ "socket.io",
13
+ "centrifugo",
14
+ "redis",
15
+ "pubsub",
12
16
  "realtime",
13
17
  "rooms",
14
18
  "namespaces",
@@ -22,6 +26,18 @@
22
26
  ".": {
23
27
  "import": "./dist/index.mjs",
24
28
  "types": "./dist/index.d.mts"
29
+ },
30
+ "./redis": {
31
+ "import": "./dist/redis.mjs",
32
+ "types": "./dist/redis.d.mts"
33
+ },
34
+ "./socket.io": {
35
+ "import": "./dist/socket-io.mjs",
36
+ "types": "./dist/socket-io.d.mts"
37
+ },
38
+ "./centrifugo": {
39
+ "import": "./dist/centrifugo.mjs",
40
+ "types": "./dist/centrifugo.d.mts"
25
41
  }
26
42
  },
27
43
  "files": [
@@ -31,13 +47,17 @@
31
47
  "reflect-metadata": "^0.2.2"
32
48
  },
33
49
  "devDependencies": {
50
+ "@socket.io/redis-adapter": "^8.3.0",
34
51
  "@swc/core": "^1.16.1",
35
52
  "@types/node": "^26.4.1",
36
53
  "@types/ws": "^8.18.0",
54
+ "ioredis": "^5.10.1",
55
+ "socket.io": "^4.8.3",
56
+ "socket.io-client": "^4.8.3",
37
57
  "typescript": "^7.0.2",
38
58
  "vitest": "^5.0.0",
39
59
  "ws": "^8.21.3",
40
- "@forinda/kickjs": "8.3.2"
60
+ "@forinda/kickjs": "8.4.0"
41
61
  },
42
62
  "publishConfig": {
43
63
  "access": "public"
@@ -58,9 +78,14 @@
58
78
  },
59
79
  "peerDependencies": {
60
80
  "@forinda/kickjs": ">=5.18.0",
81
+ "socket.io": "^4.0.0",
61
82
  "ws": "^8.0.0"
62
83
  },
63
- "peerDependenciesMeta": {},
84
+ "peerDependenciesMeta": {
85
+ "socket.io": {
86
+ "optional": true
87
+ }
88
+ },
64
89
  "scripts": {
65
90
  "build": "tsdown",
66
91
  "dev": "tsdown --watch",