@iskra-bun/socket-kit 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/index.d.ts +70 -11
- package/dist/index.js +90 -11
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/driver.ts +138 -22
- package/src/index.ts +2 -2
- package/src/router.ts +35 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @iskra-bun/socket-kit
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f9654df: New socket features:
|
|
8
|
+
|
|
9
|
+
- Rooms: `join(room)`/`leave(room)` on the socket context and `broadcastTo(room, event, payload)` on the driver (native Bun pub/sub topics). The existing global `broadcast()` is unchanged.
|
|
10
|
+
- A `socket:disconnected` event is emitted on close, as a counterpart to `socket:connected`.
|
|
11
|
+
- Each connection gets a stable unique `connectionId` (assigned at upgrade, stored on the typed socket data) included in the connected/disconnected payloads, so per-client tracking no longer relies on the non-unique remote address.
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- f9654df: The running server is now typed with Bun's `Server` instead of `any`, and `SocketContext`/`SocketHandler` accept optional payload/connection-data generics so handlers can read a typed `socket.data`. Defaults preserve existing usage.
|
|
16
|
+
- Add authorization and payload hardening to the socket driver:
|
|
17
|
+
|
|
18
|
+
- `canJoin` and `canPublish` hooks now gate room joins and publishes, so unauthorized clients can no longer subscribe to or broadcast on topics they are not permitted to use.
|
|
19
|
+
- `ctx.broadcast` wraps outgoing data in a consistent `{ event, payload }` envelope.
|
|
20
|
+
- `websocket.maxPayloadLength` is wired through (default 16 KiB) alongside a per-connection rate limit and an `allowedEvents` fallback gate, mitigating oversized-message and message-flood denial-of-service.
|
|
21
|
+
|
|
22
|
+
- Updated dependencies [f9654df]
|
|
23
|
+
- Updated dependencies
|
|
24
|
+
- Updated dependencies [f9654df]
|
|
25
|
+
- @iskra-bun/core@0.1.1
|
|
26
|
+
|
|
3
27
|
## 0.1.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
package/dist/index.d.ts
CHANGED
|
@@ -1,32 +1,91 @@
|
|
|
1
1
|
import { Context, Driver, App, IskraError } from '@iskra-bun/core';
|
|
2
2
|
import { ServerWebSocket } from 'bun';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Minimum data shape that every socket connection carries on ws.data.
|
|
6
|
+
* Drivers may extend this with app-specific fields via the TData generic.
|
|
7
|
+
*/
|
|
8
|
+
interface SocketData {
|
|
9
|
+
connectionId: string;
|
|
7
10
|
}
|
|
8
|
-
|
|
11
|
+
interface SocketContext<TPayload = unknown, TData extends SocketData = SocketData> extends Context<TPayload> {
|
|
12
|
+
socket: ServerWebSocket<TData>;
|
|
13
|
+
/** Send a message back to this socket only. */
|
|
14
|
+
reply(data: unknown): void;
|
|
15
|
+
/**
|
|
16
|
+
* Publish to all sockets subscribed to topic (e.g. 'global'). The frame is
|
|
17
|
+
* wrapped in the driver envelope: {event: topic, payload: data}. Subject to
|
|
18
|
+
* the driver's canPublish authorization hook.
|
|
19
|
+
*/
|
|
20
|
+
broadcast(topic: string, data: unknown): void;
|
|
21
|
+
/** Subscribe this socket to a named room. */
|
|
22
|
+
join(room: string): void;
|
|
23
|
+
/** Unsubscribe this socket from a named room. */
|
|
24
|
+
leave(room: string): void;
|
|
25
|
+
}
|
|
26
|
+
type SocketHandler<TPayload = unknown, TData extends SocketData = SocketData> = (ctx: SocketContext<TPayload, TData>) => Promise<void> | void;
|
|
9
27
|
declare class SocketRouter {
|
|
10
28
|
private handlers;
|
|
11
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Register a handler for an event. The generic lets callers pass a handler
|
|
31
|
+
* with a narrower payload type without an `as SocketHandler` cast; the
|
|
32
|
+
* handler is stored widened internally.
|
|
33
|
+
*/
|
|
34
|
+
on<TPayload = unknown, TData extends SocketData = SocketData>(event: string, handler: SocketHandler<TPayload, TData>): this;
|
|
12
35
|
getHandler(event: string): SocketHandler | undefined;
|
|
13
36
|
}
|
|
14
37
|
|
|
38
|
+
/** Authorization hook gating which rooms a connection may join. */
|
|
39
|
+
type CanJoin = (connection: ServerWebSocket<SocketData>, room: string) => boolean;
|
|
40
|
+
/** Authorization hook gating which topics a connection may publish to. */
|
|
41
|
+
type CanPublish = (connection: ServerWebSocket<SocketData>, topic: string) => boolean;
|
|
42
|
+
interface SocketDriverOptions {
|
|
43
|
+
port?: number;
|
|
44
|
+
router?: SocketRouter;
|
|
45
|
+
/** Max inbound frame size in bytes. Defaults to 16 KiB. */
|
|
46
|
+
maxPayloadLength?: number;
|
|
47
|
+
/** Gate ctx.join; deny by returning false. Defaults to allow-all. */
|
|
48
|
+
canJoin?: CanJoin;
|
|
49
|
+
/** Gate ctx.broadcast; deny by returning false. Defaults to allow-all. */
|
|
50
|
+
canPublish?: CanPublish;
|
|
51
|
+
/** Allowed fallback event names; when set, others are dropped. */
|
|
52
|
+
allowedEvents?: readonly string[];
|
|
53
|
+
/** Max inbound messages per connection per window. Defaults to 100. */
|
|
54
|
+
rateLimit?: number;
|
|
55
|
+
/** Rate-limit window in ms. Defaults to 1000. */
|
|
56
|
+
rateWindowMs?: number;
|
|
57
|
+
}
|
|
15
58
|
declare class SocketDriver implements Driver {
|
|
16
59
|
name: string;
|
|
17
60
|
private app;
|
|
18
61
|
private router;
|
|
19
62
|
private port;
|
|
20
63
|
private runningServer;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
64
|
+
readonly maxPayloadLength: number;
|
|
65
|
+
private readonly canJoin;
|
|
66
|
+
private readonly canPublish;
|
|
67
|
+
private readonly allowedEvents;
|
|
68
|
+
private readonly rateLimit;
|
|
69
|
+
private readonly rateWindowMs;
|
|
70
|
+
private rateStates;
|
|
71
|
+
constructor(options?: SocketDriverOptions);
|
|
25
72
|
init(app: App): void;
|
|
26
73
|
start(): void;
|
|
27
|
-
|
|
74
|
+
/** Publish to all sockets subscribed to the global topic. */
|
|
75
|
+
broadcast(event: string, payload: unknown): void;
|
|
76
|
+
/** Publish to all sockets subscribed to a specific room. */
|
|
77
|
+
broadcastTo(room: string, event: string, payload: unknown): void;
|
|
28
78
|
stop(): void;
|
|
79
|
+
/**
|
|
80
|
+
* Returns true if the connection is within its rate budget, recording the
|
|
81
|
+
* message. Bookkeeping is immutable: a fresh RateState replaces the old one.
|
|
82
|
+
*/
|
|
83
|
+
private allowMessage;
|
|
29
84
|
private handleMessage;
|
|
85
|
+
/** Subscribe to a room only when the authz hook permits it. */
|
|
86
|
+
private joinAuthorized;
|
|
87
|
+
/** Publish to a topic only when the authz hook permits it, using the envelope. */
|
|
88
|
+
private publishAuthorized;
|
|
30
89
|
}
|
|
31
90
|
|
|
32
91
|
declare class SocketConnectionError extends IskraError {
|
|
@@ -42,4 +101,4 @@ declare class SocketMessageError extends IskraError {
|
|
|
42
101
|
});
|
|
43
102
|
}
|
|
44
103
|
|
|
45
|
-
export { SocketConnectionError, type SocketContext, SocketDriver, SocketMessageError, SocketRouter };
|
|
104
|
+
export { type CanJoin, type CanPublish, SocketConnectionError, type SocketContext, type SocketData, SocketDriver, type SocketDriverOptions, type SocketHandler, SocketMessageError, SocketRouter };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// src/router.ts
|
|
2
2
|
var SocketRouter = class {
|
|
3
3
|
handlers = /* @__PURE__ */ new Map();
|
|
4
|
+
/**
|
|
5
|
+
* Register a handler for an event. The generic lets callers pass a handler
|
|
6
|
+
* with a narrower payload type without an `as SocketHandler` cast; the
|
|
7
|
+
* handler is stored widened internally.
|
|
8
|
+
*/
|
|
4
9
|
on(event, handler) {
|
|
5
10
|
this.handlers.set(event, handler);
|
|
6
11
|
return this;
|
|
@@ -26,15 +31,31 @@ var SocketMessageError = class extends IskraError {
|
|
|
26
31
|
};
|
|
27
32
|
|
|
28
33
|
// src/driver.ts
|
|
34
|
+
var DEFAULT_MAX_PAYLOAD_LENGTH = 16 * 1024;
|
|
35
|
+
var DEFAULT_RATE_LIMIT = 100;
|
|
36
|
+
var DEFAULT_RATE_WINDOW_MS = 1e3;
|
|
29
37
|
var SocketDriver = class {
|
|
30
38
|
name = "SocketDriver";
|
|
31
39
|
app = null;
|
|
32
40
|
router;
|
|
33
41
|
port;
|
|
34
|
-
runningServer;
|
|
42
|
+
runningServer = null;
|
|
43
|
+
maxPayloadLength;
|
|
44
|
+
canJoin;
|
|
45
|
+
canPublish;
|
|
46
|
+
allowedEvents;
|
|
47
|
+
rateLimit;
|
|
48
|
+
rateWindowMs;
|
|
49
|
+
rateStates = /* @__PURE__ */ new Map();
|
|
35
50
|
constructor(options = {}) {
|
|
36
51
|
this.port = options.port || 3001;
|
|
37
52
|
this.router = options.router || new SocketRouter();
|
|
53
|
+
this.maxPayloadLength = options.maxPayloadLength ?? DEFAULT_MAX_PAYLOAD_LENGTH;
|
|
54
|
+
this.canJoin = options.canJoin ?? (() => true);
|
|
55
|
+
this.canPublish = options.canPublish ?? (() => true);
|
|
56
|
+
this.allowedEvents = options.allowedEvents ? new Set(options.allowedEvents) : null;
|
|
57
|
+
this.rateLimit = options.rateLimit ?? DEFAULT_RATE_LIMIT;
|
|
58
|
+
this.rateWindowMs = options.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS;
|
|
38
59
|
}
|
|
39
60
|
init(app) {
|
|
40
61
|
this.app = app;
|
|
@@ -44,40 +65,68 @@ var SocketDriver = class {
|
|
|
44
65
|
this.runningServer = Bun.serve({
|
|
45
66
|
port: this.port,
|
|
46
67
|
fetch(req, server) {
|
|
47
|
-
|
|
68
|
+
const connectionId = crypto.randomUUID();
|
|
69
|
+
if (server.upgrade(req, { data: { connectionId } })) {
|
|
48
70
|
return;
|
|
49
71
|
}
|
|
50
72
|
return new Response("Upgrade failed", { status: 500 });
|
|
51
73
|
},
|
|
52
74
|
websocket: {
|
|
75
|
+
maxPayloadLength: this.maxPayloadLength,
|
|
53
76
|
open: (ws) => {
|
|
54
77
|
this.app?.logger.debug("Socket connected");
|
|
55
78
|
ws.subscribe("global");
|
|
56
|
-
this.app?.emit("socket:connected", {
|
|
79
|
+
this.app?.emit("socket:connected", {
|
|
80
|
+
connectionId: ws.data.connectionId
|
|
81
|
+
});
|
|
57
82
|
},
|
|
58
83
|
message: async (ws, message) => {
|
|
59
84
|
await this.handleMessage(ws, message);
|
|
60
85
|
},
|
|
61
86
|
close: (ws) => {
|
|
62
87
|
ws.unsubscribe("global");
|
|
88
|
+
this.rateStates.delete(ws.data.connectionId);
|
|
63
89
|
this.app?.logger.debug("Socket disconnected");
|
|
90
|
+
this.app?.emit("socket:disconnected", {
|
|
91
|
+
connectionId: ws.data.connectionId
|
|
92
|
+
});
|
|
64
93
|
}
|
|
65
94
|
}
|
|
66
95
|
});
|
|
67
96
|
}
|
|
97
|
+
/** Publish to all sockets subscribed to the global topic. */
|
|
68
98
|
broadcast(event, payload) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
99
|
+
this.runningServer?.publish("global", JSON.stringify({ event, payload }));
|
|
100
|
+
}
|
|
101
|
+
/** Publish to all sockets subscribed to a specific room. */
|
|
102
|
+
broadcastTo(room, event, payload) {
|
|
103
|
+
this.runningServer?.publish(room, JSON.stringify({ event, payload }));
|
|
72
104
|
}
|
|
73
105
|
stop() {
|
|
74
|
-
|
|
75
|
-
this.runningServer.stop();
|
|
76
|
-
}
|
|
106
|
+
this.runningServer?.stop();
|
|
77
107
|
this.app?.logger.info("SocketDriver stopped");
|
|
78
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Returns true if the connection is within its rate budget, recording the
|
|
111
|
+
* message. Bookkeeping is immutable: a fresh RateState replaces the old one.
|
|
112
|
+
*/
|
|
113
|
+
allowMessage(ws) {
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
const key = ws.data.connectionId;
|
|
116
|
+
const prev = this.rateStates.get(key);
|
|
117
|
+
const next = !prev || now - prev.windowStart >= this.rateWindowMs ? { count: 1, windowStart: now } : { count: prev.count + 1, windowStart: prev.windowStart };
|
|
118
|
+
this.rateStates.set(key, next);
|
|
119
|
+
return next.count <= this.rateLimit;
|
|
120
|
+
}
|
|
79
121
|
async handleMessage(ws, message) {
|
|
80
122
|
try {
|
|
123
|
+
if (!this.allowMessage(ws)) {
|
|
124
|
+
this.app?.logger.warn(
|
|
125
|
+
{ connectionId: ws.data.connectionId },
|
|
126
|
+
"Socket message rate limit exceeded; dropping frame"
|
|
127
|
+
);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
81
130
|
const text = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
82
131
|
const eventData = JSON.parse(text);
|
|
83
132
|
const { event, payload } = eventData;
|
|
@@ -90,10 +139,18 @@ var SocketDriver = class {
|
|
|
90
139
|
payload: payload || {},
|
|
91
140
|
socket: ws,
|
|
92
141
|
reply: (data) => ws.send(JSON.stringify({ event: `${event}:reply`, payload: data })),
|
|
93
|
-
broadcast: (
|
|
94
|
-
|
|
142
|
+
broadcast: (topic, data) => this.publishAuthorized(ws, topic, data),
|
|
143
|
+
join: (room) => this.joinAuthorized(ws, room),
|
|
144
|
+
leave: (room) => ws.unsubscribe(room)
|
|
95
145
|
});
|
|
96
146
|
} else {
|
|
147
|
+
if (this.allowedEvents && !this.allowedEvents.has(event)) {
|
|
148
|
+
this.app?.logger.warn(
|
|
149
|
+
{ event },
|
|
150
|
+
"Dropping fallback socket event not in the allowed set"
|
|
151
|
+
);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
97
154
|
this.app?.emit(`socket:${event}`, { socket: ws, payload });
|
|
98
155
|
}
|
|
99
156
|
} catch (err) {
|
|
@@ -103,6 +160,28 @@ var SocketDriver = class {
|
|
|
103
160
|
this.app?.logger.error({ err: socketErr }, socketErr.message);
|
|
104
161
|
}
|
|
105
162
|
}
|
|
163
|
+
/** Subscribe to a room only when the authz hook permits it. */
|
|
164
|
+
joinAuthorized(ws, room) {
|
|
165
|
+
if (!this.canJoin(ws, room)) {
|
|
166
|
+
this.app?.logger.warn(
|
|
167
|
+
{ connectionId: ws.data.connectionId, room },
|
|
168
|
+
"Denied socket join to unauthorized room"
|
|
169
|
+
);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
ws.subscribe(room);
|
|
173
|
+
}
|
|
174
|
+
/** Publish to a topic only when the authz hook permits it, using the envelope. */
|
|
175
|
+
publishAuthorized(ws, topic, data) {
|
|
176
|
+
if (!this.canPublish(ws, topic)) {
|
|
177
|
+
this.app?.logger.warn(
|
|
178
|
+
{ connectionId: ws.data.connectionId, topic },
|
|
179
|
+
"Denied socket broadcast to unauthorized topic"
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
this.runningServer?.publish(topic, JSON.stringify({ event: topic, payload: data }));
|
|
184
|
+
}
|
|
106
185
|
};
|
|
107
186
|
export {
|
|
108
187
|
SocketConnectionError,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/router.ts","../src/errors.ts","../src/driver.ts"],"sourcesContent":["import type { App, Context } from '@iskra-bun/core';\nimport type { ServerWebSocket } from 'bun';\n\nexport interface SocketContext<T = any> extends Context<T> {\n socket: ServerWebSocket<any>;\n broadcast(event: string, payload: any): void;\n}\n\nexport type SocketHandler = (ctx: SocketContext) => Promise<void> | void;\n\nexport class SocketRouter {\n private handlers: Map<string, SocketHandler> = new Map();\n\n on(event: string, handler: SocketHandler) {\n this.handlers.set(event, handler);\n return this;\n }\n\n getHandler(event: string): SocketHandler | undefined {\n return this.handlers.get(event);\n }\n}\n","import { IskraError, ErrorCodes } from '@iskra-bun/core';\n\n// ─── Socket Connection Error ─────────────────────────────────────────────────\n\nexport class SocketConnectionError extends IskraError {\n constructor(message: string, options?: { cause?: Error; context?: Record<string, unknown> }) {\n super(message, { code: ErrorCodes.SOCKET_CONNECTION_ERROR, ...options });\n this.name = 'SocketConnectionError';\n }\n}\n\n// ─── Socket Message Error ────────────────────────────────────────────────────\n\nexport class SocketMessageError extends IskraError {\n constructor(message: string, options?: { cause?: Error; context?: Record<string, unknown> }) {\n super(message, { code: ErrorCodes.SOCKET_MESSAGE_ERROR, ...options });\n this.name = 'SocketMessageError';\n }\n}\n","import type { App, Driver } from '@iskra-bun/core';\nimport { SocketRouter } from './router';\nimport type { ServerWebSocket } from 'bun';\nimport { SocketMessageError } from './errors';\n\nexport class SocketDriver implements Driver {\n name = 'SocketDriver';\n private app: App | null = null;\n private router: SocketRouter;\n private port: number;\n private runningServer: any;\n\n constructor(options: { port?: number; router?: SocketRouter } = {}) {\n this.port = options.port || 3001;\n this.router = options.router || new SocketRouter();\n }\n\n init(app: App) {\n this.app = app;\n }\n\n start() {\n this.app?.logger.info(`Starting SocketDriver on port ${this.port}...`);\n\n this.runningServer = Bun.serve({\n port: this.port,\n fetch(req, server) {\n // Upgrade logic\n if (server.upgrade(req)) {\n return; // Bun handles the rest\n }\n return new Response(\"Upgrade failed\", { status: 500 });\n },\n websocket: {\n open: (ws) => {\n this.app?.logger.debug('Socket connected');\n ws.subscribe('global');\n this.app?.emit('socket:connected', { id: ws.remoteAddress });\n },\n message: async (ws, message) => {\n await this.handleMessage(ws, message);\n },\n close: (ws) => {\n ws.unsubscribe('global');\n this.app?.logger.debug('Socket disconnected');\n }\n }\n });\n }\n\n public broadcast(event: string, payload: any) {\n if (this.runningServer) {\n this.runningServer.publish('global', JSON.stringify({ event, payload }));\n }\n }\n\n stop() {\n if (this.runningServer) {\n this.runningServer.stop();\n }\n this.app?.logger.info('SocketDriver stopped');\n }\n\n private async handleMessage(ws: ServerWebSocket<any>, message: string | Buffer) {\n try {\n const text = typeof message === 'string' ? message : new TextDecoder().decode(message);\n const eventData = JSON.parse(text);\n const { event, payload } = eventData;\n\n if (!event) return;\n\n // 1. Try Router\n const handler = this.router.getHandler(event);\n if (handler && this.app) {\n await handler({\n app: this.app,\n logger: this.app.logger.child({ source: 'socket', event }),\n payload: payload || {},\n socket: ws,\n reply: (data) => ws.send(JSON.stringify({ event: `${event}:reply`, payload: data })),\n broadcast: (evt, data) => this.runningServer.publish(evt, JSON.stringify(data)) // Publish to topic/channel logic needed? Or just broadcast\n });\n } else {\n // 2. Fallback to Global App Event\n this.app?.emit(`socket:${event}`, { socket: ws, payload });\n }\n\n } catch (err) {\n const socketErr = new SocketMessageError('Failed to handle socket message', {\n cause: err instanceof Error ? err : new Error(String(err)),\n });\n this.app?.logger.error({ err: socketErr }, socketErr.message);\n }\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/router.ts","../src/errors.ts","../src/driver.ts"],"sourcesContent":["import type { App, Context } from '@iskra-bun/core';\nimport type { ServerWebSocket } from 'bun';\n\n/**\n * Minimum data shape that every socket connection carries on ws.data.\n * Drivers may extend this with app-specific fields via the TData generic.\n */\nexport interface SocketData {\n connectionId: string;\n}\n\nexport interface SocketContext<TPayload = unknown, TData extends SocketData = SocketData> extends Context<TPayload> {\n socket: ServerWebSocket<TData>;\n /** Send a message back to this socket only. */\n reply(data: unknown): void;\n /**\n * Publish to all sockets subscribed to topic (e.g. 'global'). The frame is\n * wrapped in the driver envelope: {event: topic, payload: data}. Subject to\n * the driver's canPublish authorization hook.\n */\n broadcast(topic: string, data: unknown): void;\n /** Subscribe this socket to a named room. */\n join(room: string): void;\n /** Unsubscribe this socket from a named room. */\n leave(room: string): void;\n}\n\nexport type SocketHandler<TPayload = unknown, TData extends SocketData = SocketData> = (\n ctx: SocketContext<TPayload, TData>\n) => Promise<void> | void;\n\nexport class SocketRouter {\n private handlers: Map<string, SocketHandler> = new Map();\n\n /**\n * Register a handler for an event. The generic lets callers pass a handler\n * with a narrower payload type without an `as SocketHandler` cast; the\n * handler is stored widened internally.\n */\n on<TPayload = unknown, TData extends SocketData = SocketData>(\n event: string,\n handler: SocketHandler<TPayload, TData>\n ) {\n this.handlers.set(event, handler as SocketHandler);\n return this;\n }\n\n getHandler(event: string): SocketHandler | undefined {\n return this.handlers.get(event);\n }\n}\n","import { IskraError, ErrorCodes } from '@iskra-bun/core';\n\n// ─── Socket Connection Error ─────────────────────────────────────────────────\n\nexport class SocketConnectionError extends IskraError {\n constructor(message: string, options?: { cause?: Error; context?: Record<string, unknown> }) {\n super(message, { code: ErrorCodes.SOCKET_CONNECTION_ERROR, ...options });\n this.name = 'SocketConnectionError';\n }\n}\n\n// ─── Socket Message Error ────────────────────────────────────────────────────\n\nexport class SocketMessageError extends IskraError {\n constructor(message: string, options?: { cause?: Error; context?: Record<string, unknown> }) {\n super(message, { code: ErrorCodes.SOCKET_MESSAGE_ERROR, ...options });\n this.name = 'SocketMessageError';\n }\n}\n","import type { App, Driver } from '@iskra-bun/core';\nimport { SocketRouter } from './router';\nimport type { SocketData } from './router';\nimport type { Server, ServerWebSocket } from 'bun';\nimport { SocketMessageError } from './errors';\n\n/** Default cap on a single inbound frame (16 KiB) to bound JSON.parse cost. */\nconst DEFAULT_MAX_PAYLOAD_LENGTH = 16 * 1024;\n/** Default per-connection inbound message budget per rate window. */\nconst DEFAULT_RATE_LIMIT = 100;\n/** Default rate-limit window in milliseconds. */\nconst DEFAULT_RATE_WINDOW_MS = 1000;\n\n/** Authorization hook gating which rooms a connection may join. */\nexport type CanJoin = (connection: ServerWebSocket<SocketData>, room: string) => boolean;\n/** Authorization hook gating which topics a connection may publish to. */\nexport type CanPublish = (connection: ServerWebSocket<SocketData>, topic: string) => boolean;\n\nexport interface SocketDriverOptions {\n port?: number;\n router?: SocketRouter;\n /** Max inbound frame size in bytes. Defaults to 16 KiB. */\n maxPayloadLength?: number;\n /** Gate ctx.join; deny by returning false. Defaults to allow-all. */\n canJoin?: CanJoin;\n /** Gate ctx.broadcast; deny by returning false. Defaults to allow-all. */\n canPublish?: CanPublish;\n /** Allowed fallback event names; when set, others are dropped. */\n allowedEvents?: readonly string[];\n /** Max inbound messages per connection per window. Defaults to 100. */\n rateLimit?: number;\n /** Rate-limit window in ms. Defaults to 1000. */\n rateWindowMs?: number;\n}\n\n/** Per-connection rate-limit bookkeeping, replaced immutably on each update. */\ninterface RateState {\n count: number;\n windowStart: number;\n}\n\nexport class SocketDriver implements Driver {\n name = 'SocketDriver';\n private app: App | null = null;\n private router: SocketRouter;\n private port: number;\n private runningServer: Server<SocketData> | null = null;\n\n public readonly maxPayloadLength: number;\n private readonly canJoin: CanJoin;\n private readonly canPublish: CanPublish;\n private readonly allowedEvents: ReadonlySet<string> | null;\n private readonly rateLimit: number;\n private readonly rateWindowMs: number;\n private rateStates: Map<string, RateState> = new Map();\n\n constructor(options: SocketDriverOptions = {}) {\n this.port = options.port || 3001;\n this.router = options.router || new SocketRouter();\n this.maxPayloadLength = options.maxPayloadLength ?? DEFAULT_MAX_PAYLOAD_LENGTH;\n this.canJoin = options.canJoin ?? (() => true);\n this.canPublish = options.canPublish ?? (() => true);\n this.allowedEvents = options.allowedEvents ? new Set(options.allowedEvents) : null;\n this.rateLimit = options.rateLimit ?? DEFAULT_RATE_LIMIT;\n this.rateWindowMs = options.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS;\n }\n\n init(app: App) {\n this.app = app;\n }\n\n start() {\n this.app?.logger.info(`Starting SocketDriver on port ${this.port}...`);\n\n this.runningServer = Bun.serve<SocketData>({\n port: this.port,\n fetch(req, server) {\n // Assign a unique id per connection at upgrade time.\n const connectionId = crypto.randomUUID();\n if (server.upgrade(req, { data: { connectionId } })) {\n return; // Bun handles the rest\n }\n return new Response('Upgrade failed', { status: 500 });\n },\n websocket: {\n maxPayloadLength: this.maxPayloadLength,\n open: (ws) => {\n this.app?.logger.debug('Socket connected');\n ws.subscribe('global');\n this.app?.emit('socket:connected', {\n connectionId: ws.data.connectionId,\n });\n },\n message: async (ws, message) => {\n await this.handleMessage(ws, message);\n },\n close: (ws) => {\n ws.unsubscribe('global');\n this.rateStates.delete(ws.data.connectionId);\n this.app?.logger.debug('Socket disconnected');\n this.app?.emit('socket:disconnected', {\n connectionId: ws.data.connectionId,\n });\n },\n },\n });\n }\n\n /** Publish to all sockets subscribed to the global topic. */\n public broadcast(event: string, payload: unknown) {\n this.runningServer?.publish('global', JSON.stringify({ event, payload }));\n }\n\n /** Publish to all sockets subscribed to a specific room. */\n public broadcastTo(room: string, event: string, payload: unknown) {\n this.runningServer?.publish(room, JSON.stringify({ event, payload }));\n }\n\n stop() {\n this.runningServer?.stop();\n this.app?.logger.info('SocketDriver stopped');\n }\n\n /**\n * Returns true if the connection is within its rate budget, recording the\n * message. Bookkeeping is immutable: a fresh RateState replaces the old one.\n */\n private allowMessage(ws: ServerWebSocket<SocketData>): boolean {\n const now = Date.now();\n const key = ws.data.connectionId;\n const prev = this.rateStates.get(key);\n const next: RateState =\n !prev || now - prev.windowStart >= this.rateWindowMs\n ? { count: 1, windowStart: now }\n : { count: prev.count + 1, windowStart: prev.windowStart };\n this.rateStates.set(key, next);\n return next.count <= this.rateLimit;\n }\n\n private async handleMessage(ws: ServerWebSocket<SocketData>, message: string | Buffer) {\n try {\n if (!this.allowMessage(ws)) {\n this.app?.logger.warn(\n { connectionId: ws.data.connectionId },\n 'Socket message rate limit exceeded; dropping frame'\n );\n return;\n }\n\n const text = typeof message === 'string' ? message : new TextDecoder().decode(message);\n const eventData = JSON.parse(text);\n const { event, payload } = eventData;\n\n if (!event) return;\n\n const handler = this.router.getHandler(event);\n if (handler && this.app) {\n await handler({\n app: this.app,\n logger: this.app.logger.child({ source: 'socket', event }),\n payload: payload || {},\n socket: ws,\n reply: (data) => ws.send(JSON.stringify({ event: `${event}:reply`, payload: data })),\n broadcast: (topic, data) => this.publishAuthorized(ws, topic, data),\n join: (room) => this.joinAuthorized(ws, room),\n leave: (room) => ws.unsubscribe(room),\n });\n } else {\n // Fallback to the global app event bus, but only for events that\n // pass the allow-list (when configured). Unknown names are dropped.\n if (this.allowedEvents && !this.allowedEvents.has(event)) {\n this.app?.logger.warn(\n { event },\n 'Dropping fallback socket event not in the allowed set'\n );\n return;\n }\n this.app?.emit(`socket:${event}`, { socket: ws, payload });\n }\n } catch (err) {\n const socketErr = new SocketMessageError('Failed to handle socket message', {\n cause: err instanceof Error ? err : new Error(String(err)),\n });\n this.app?.logger.error({ err: socketErr }, socketErr.message);\n }\n }\n\n /** Subscribe to a room only when the authz hook permits it. */\n private joinAuthorized(ws: ServerWebSocket<SocketData>, room: string) {\n if (!this.canJoin(ws, room)) {\n this.app?.logger.warn(\n { connectionId: ws.data.connectionId, room },\n 'Denied socket join to unauthorized room'\n );\n return;\n }\n ws.subscribe(room);\n }\n\n /** Publish to a topic only when the authz hook permits it, using the envelope. */\n private publishAuthorized(ws: ServerWebSocket<SocketData>, topic: string, data: unknown) {\n if (!this.canPublish(ws, topic)) {\n this.app?.logger.warn(\n { connectionId: ws.data.connectionId, topic },\n 'Denied socket broadcast to unauthorized topic'\n );\n return;\n }\n this.runningServer?.publish(topic, JSON.stringify({ event: topic, payload: data }));\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iskra-bun/socket-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "WebSocket nativo de Bun para Iskra, con router y broadcast.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"iskra",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"build": "tsup --config ../../tsup.config.ts"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@iskra-bun/core": "0.1.
|
|
49
|
+
"@iskra-bun/core": "0.1.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/bun": "^1.3.5",
|
package/src/driver.ts
CHANGED
|
@@ -1,18 +1,68 @@
|
|
|
1
1
|
import type { App, Driver } from '@iskra-bun/core';
|
|
2
2
|
import { SocketRouter } from './router';
|
|
3
|
-
import type {
|
|
3
|
+
import type { SocketData } from './router';
|
|
4
|
+
import type { Server, ServerWebSocket } from 'bun';
|
|
4
5
|
import { SocketMessageError } from './errors';
|
|
5
6
|
|
|
7
|
+
/** Default cap on a single inbound frame (16 KiB) to bound JSON.parse cost. */
|
|
8
|
+
const DEFAULT_MAX_PAYLOAD_LENGTH = 16 * 1024;
|
|
9
|
+
/** Default per-connection inbound message budget per rate window. */
|
|
10
|
+
const DEFAULT_RATE_LIMIT = 100;
|
|
11
|
+
/** Default rate-limit window in milliseconds. */
|
|
12
|
+
const DEFAULT_RATE_WINDOW_MS = 1000;
|
|
13
|
+
|
|
14
|
+
/** Authorization hook gating which rooms a connection may join. */
|
|
15
|
+
export type CanJoin = (connection: ServerWebSocket<SocketData>, room: string) => boolean;
|
|
16
|
+
/** Authorization hook gating which topics a connection may publish to. */
|
|
17
|
+
export type CanPublish = (connection: ServerWebSocket<SocketData>, topic: string) => boolean;
|
|
18
|
+
|
|
19
|
+
export interface SocketDriverOptions {
|
|
20
|
+
port?: number;
|
|
21
|
+
router?: SocketRouter;
|
|
22
|
+
/** Max inbound frame size in bytes. Defaults to 16 KiB. */
|
|
23
|
+
maxPayloadLength?: number;
|
|
24
|
+
/** Gate ctx.join; deny by returning false. Defaults to allow-all. */
|
|
25
|
+
canJoin?: CanJoin;
|
|
26
|
+
/** Gate ctx.broadcast; deny by returning false. Defaults to allow-all. */
|
|
27
|
+
canPublish?: CanPublish;
|
|
28
|
+
/** Allowed fallback event names; when set, others are dropped. */
|
|
29
|
+
allowedEvents?: readonly string[];
|
|
30
|
+
/** Max inbound messages per connection per window. Defaults to 100. */
|
|
31
|
+
rateLimit?: number;
|
|
32
|
+
/** Rate-limit window in ms. Defaults to 1000. */
|
|
33
|
+
rateWindowMs?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Per-connection rate-limit bookkeeping, replaced immutably on each update. */
|
|
37
|
+
interface RateState {
|
|
38
|
+
count: number;
|
|
39
|
+
windowStart: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
6
42
|
export class SocketDriver implements Driver {
|
|
7
43
|
name = 'SocketDriver';
|
|
8
44
|
private app: App | null = null;
|
|
9
45
|
private router: SocketRouter;
|
|
10
46
|
private port: number;
|
|
11
|
-
private runningServer:
|
|
47
|
+
private runningServer: Server<SocketData> | null = null;
|
|
48
|
+
|
|
49
|
+
public readonly maxPayloadLength: number;
|
|
50
|
+
private readonly canJoin: CanJoin;
|
|
51
|
+
private readonly canPublish: CanPublish;
|
|
52
|
+
private readonly allowedEvents: ReadonlySet<string> | null;
|
|
53
|
+
private readonly rateLimit: number;
|
|
54
|
+
private readonly rateWindowMs: number;
|
|
55
|
+
private rateStates: Map<string, RateState> = new Map();
|
|
12
56
|
|
|
13
|
-
constructor(options:
|
|
57
|
+
constructor(options: SocketDriverOptions = {}) {
|
|
14
58
|
this.port = options.port || 3001;
|
|
15
59
|
this.router = options.router || new SocketRouter();
|
|
60
|
+
this.maxPayloadLength = options.maxPayloadLength ?? DEFAULT_MAX_PAYLOAD_LENGTH;
|
|
61
|
+
this.canJoin = options.canJoin ?? (() => true);
|
|
62
|
+
this.canPublish = options.canPublish ?? (() => true);
|
|
63
|
+
this.allowedEvents = options.allowedEvents ? new Set(options.allowedEvents) : null;
|
|
64
|
+
this.rateLimit = options.rateLimit ?? DEFAULT_RATE_LIMIT;
|
|
65
|
+
this.rateWindowMs = options.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS;
|
|
16
66
|
}
|
|
17
67
|
|
|
18
68
|
init(app: App) {
|
|
@@ -22,54 +72,87 @@ export class SocketDriver implements Driver {
|
|
|
22
72
|
start() {
|
|
23
73
|
this.app?.logger.info(`Starting SocketDriver on port ${this.port}...`);
|
|
24
74
|
|
|
25
|
-
this.runningServer = Bun.serve({
|
|
75
|
+
this.runningServer = Bun.serve<SocketData>({
|
|
26
76
|
port: this.port,
|
|
27
77
|
fetch(req, server) {
|
|
28
|
-
//
|
|
29
|
-
|
|
78
|
+
// Assign a unique id per connection at upgrade time.
|
|
79
|
+
const connectionId = crypto.randomUUID();
|
|
80
|
+
if (server.upgrade(req, { data: { connectionId } })) {
|
|
30
81
|
return; // Bun handles the rest
|
|
31
82
|
}
|
|
32
|
-
return new Response(
|
|
83
|
+
return new Response('Upgrade failed', { status: 500 });
|
|
33
84
|
},
|
|
34
85
|
websocket: {
|
|
86
|
+
maxPayloadLength: this.maxPayloadLength,
|
|
35
87
|
open: (ws) => {
|
|
36
88
|
this.app?.logger.debug('Socket connected');
|
|
37
89
|
ws.subscribe('global');
|
|
38
|
-
this.app?.emit('socket:connected', {
|
|
90
|
+
this.app?.emit('socket:connected', {
|
|
91
|
+
connectionId: ws.data.connectionId,
|
|
92
|
+
});
|
|
39
93
|
},
|
|
40
94
|
message: async (ws, message) => {
|
|
41
95
|
await this.handleMessage(ws, message);
|
|
42
96
|
},
|
|
43
97
|
close: (ws) => {
|
|
44
98
|
ws.unsubscribe('global');
|
|
99
|
+
this.rateStates.delete(ws.data.connectionId);
|
|
45
100
|
this.app?.logger.debug('Socket disconnected');
|
|
46
|
-
|
|
47
|
-
|
|
101
|
+
this.app?.emit('socket:disconnected', {
|
|
102
|
+
connectionId: ws.data.connectionId,
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
},
|
|
48
106
|
});
|
|
49
107
|
}
|
|
50
108
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
109
|
+
/** Publish to all sockets subscribed to the global topic. */
|
|
110
|
+
public broadcast(event: string, payload: unknown) {
|
|
111
|
+
this.runningServer?.publish('global', JSON.stringify({ event, payload }));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Publish to all sockets subscribed to a specific room. */
|
|
115
|
+
public broadcastTo(room: string, event: string, payload: unknown) {
|
|
116
|
+
this.runningServer?.publish(room, JSON.stringify({ event, payload }));
|
|
55
117
|
}
|
|
56
118
|
|
|
57
119
|
stop() {
|
|
58
|
-
|
|
59
|
-
this.runningServer.stop();
|
|
60
|
-
}
|
|
120
|
+
this.runningServer?.stop();
|
|
61
121
|
this.app?.logger.info('SocketDriver stopped');
|
|
62
122
|
}
|
|
63
123
|
|
|
64
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Returns true if the connection is within its rate budget, recording the
|
|
126
|
+
* message. Bookkeeping is immutable: a fresh RateState replaces the old one.
|
|
127
|
+
*/
|
|
128
|
+
private allowMessage(ws: ServerWebSocket<SocketData>): boolean {
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
const key = ws.data.connectionId;
|
|
131
|
+
const prev = this.rateStates.get(key);
|
|
132
|
+
const next: RateState =
|
|
133
|
+
!prev || now - prev.windowStart >= this.rateWindowMs
|
|
134
|
+
? { count: 1, windowStart: now }
|
|
135
|
+
: { count: prev.count + 1, windowStart: prev.windowStart };
|
|
136
|
+
this.rateStates.set(key, next);
|
|
137
|
+
return next.count <= this.rateLimit;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async handleMessage(ws: ServerWebSocket<SocketData>, message: string | Buffer) {
|
|
65
141
|
try {
|
|
142
|
+
if (!this.allowMessage(ws)) {
|
|
143
|
+
this.app?.logger.warn(
|
|
144
|
+
{ connectionId: ws.data.connectionId },
|
|
145
|
+
'Socket message rate limit exceeded; dropping frame'
|
|
146
|
+
);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
66
150
|
const text = typeof message === 'string' ? message : new TextDecoder().decode(message);
|
|
67
151
|
const eventData = JSON.parse(text);
|
|
68
152
|
const { event, payload } = eventData;
|
|
69
153
|
|
|
70
154
|
if (!event) return;
|
|
71
155
|
|
|
72
|
-
// 1. Try Router
|
|
73
156
|
const handler = this.router.getHandler(event);
|
|
74
157
|
if (handler && this.app) {
|
|
75
158
|
await handler({
|
|
@@ -78,13 +161,22 @@ export class SocketDriver implements Driver {
|
|
|
78
161
|
payload: payload || {},
|
|
79
162
|
socket: ws,
|
|
80
163
|
reply: (data) => ws.send(JSON.stringify({ event: `${event}:reply`, payload: data })),
|
|
81
|
-
broadcast: (
|
|
164
|
+
broadcast: (topic, data) => this.publishAuthorized(ws, topic, data),
|
|
165
|
+
join: (room) => this.joinAuthorized(ws, room),
|
|
166
|
+
leave: (room) => ws.unsubscribe(room),
|
|
82
167
|
});
|
|
83
168
|
} else {
|
|
84
|
-
//
|
|
169
|
+
// Fallback to the global app event bus, but only for events that
|
|
170
|
+
// pass the allow-list (when configured). Unknown names are dropped.
|
|
171
|
+
if (this.allowedEvents && !this.allowedEvents.has(event)) {
|
|
172
|
+
this.app?.logger.warn(
|
|
173
|
+
{ event },
|
|
174
|
+
'Dropping fallback socket event not in the allowed set'
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
85
178
|
this.app?.emit(`socket:${event}`, { socket: ws, payload });
|
|
86
179
|
}
|
|
87
|
-
|
|
88
180
|
} catch (err) {
|
|
89
181
|
const socketErr = new SocketMessageError('Failed to handle socket message', {
|
|
90
182
|
cause: err instanceof Error ? err : new Error(String(err)),
|
|
@@ -92,4 +184,28 @@ export class SocketDriver implements Driver {
|
|
|
92
184
|
this.app?.logger.error({ err: socketErr }, socketErr.message);
|
|
93
185
|
}
|
|
94
186
|
}
|
|
187
|
+
|
|
188
|
+
/** Subscribe to a room only when the authz hook permits it. */
|
|
189
|
+
private joinAuthorized(ws: ServerWebSocket<SocketData>, room: string) {
|
|
190
|
+
if (!this.canJoin(ws, room)) {
|
|
191
|
+
this.app?.logger.warn(
|
|
192
|
+
{ connectionId: ws.data.connectionId, room },
|
|
193
|
+
'Denied socket join to unauthorized room'
|
|
194
|
+
);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
ws.subscribe(room);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Publish to a topic only when the authz hook permits it, using the envelope. */
|
|
201
|
+
private publishAuthorized(ws: ServerWebSocket<SocketData>, topic: string, data: unknown) {
|
|
202
|
+
if (!this.canPublish(ws, topic)) {
|
|
203
|
+
this.app?.logger.warn(
|
|
204
|
+
{ connectionId: ws.data.connectionId, topic },
|
|
205
|
+
'Denied socket broadcast to unauthorized topic'
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
this.runningServer?.publish(topic, JSON.stringify({ event: topic, payload: data }));
|
|
210
|
+
}
|
|
95
211
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { SocketDriver } from './driver';
|
|
2
|
-
export { SocketRouter, type SocketContext } from './router';
|
|
1
|
+
export { SocketDriver, type SocketDriverOptions, type CanJoin, type CanPublish } from './driver';
|
|
2
|
+
export { SocketRouter, type SocketContext, type SocketHandler, type SocketData } from './router';
|
|
3
3
|
export * from './errors';
|
package/src/router.ts
CHANGED
|
@@ -1,18 +1,47 @@
|
|
|
1
1
|
import type { App, Context } from '@iskra-bun/core';
|
|
2
2
|
import type { ServerWebSocket } from 'bun';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Minimum data shape that every socket connection carries on ws.data.
|
|
6
|
+
* Drivers may extend this with app-specific fields via the TData generic.
|
|
7
|
+
*/
|
|
8
|
+
export interface SocketData {
|
|
9
|
+
connectionId: string;
|
|
7
10
|
}
|
|
8
11
|
|
|
9
|
-
export
|
|
12
|
+
export interface SocketContext<TPayload = unknown, TData extends SocketData = SocketData> extends Context<TPayload> {
|
|
13
|
+
socket: ServerWebSocket<TData>;
|
|
14
|
+
/** Send a message back to this socket only. */
|
|
15
|
+
reply(data: unknown): void;
|
|
16
|
+
/**
|
|
17
|
+
* Publish to all sockets subscribed to topic (e.g. 'global'). The frame is
|
|
18
|
+
* wrapped in the driver envelope: {event: topic, payload: data}. Subject to
|
|
19
|
+
* the driver's canPublish authorization hook.
|
|
20
|
+
*/
|
|
21
|
+
broadcast(topic: string, data: unknown): void;
|
|
22
|
+
/** Subscribe this socket to a named room. */
|
|
23
|
+
join(room: string): void;
|
|
24
|
+
/** Unsubscribe this socket from a named room. */
|
|
25
|
+
leave(room: string): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type SocketHandler<TPayload = unknown, TData extends SocketData = SocketData> = (
|
|
29
|
+
ctx: SocketContext<TPayload, TData>
|
|
30
|
+
) => Promise<void> | void;
|
|
10
31
|
|
|
11
32
|
export class SocketRouter {
|
|
12
33
|
private handlers: Map<string, SocketHandler> = new Map();
|
|
13
34
|
|
|
14
|
-
|
|
15
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Register a handler for an event. The generic lets callers pass a handler
|
|
37
|
+
* with a narrower payload type without an `as SocketHandler` cast; the
|
|
38
|
+
* handler is stored widened internally.
|
|
39
|
+
*/
|
|
40
|
+
on<TPayload = unknown, TData extends SocketData = SocketData>(
|
|
41
|
+
event: string,
|
|
42
|
+
handler: SocketHandler<TPayload, TData>
|
|
43
|
+
) {
|
|
44
|
+
this.handlers.set(event, handler as SocketHandler);
|
|
16
45
|
return this;
|
|
17
46
|
}
|
|
18
47
|
|