@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 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
- interface SocketContext<T = any> extends Context<T> {
5
- socket: ServerWebSocket<any>;
6
- broadcast(event: string, payload: any): void;
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
- type SocketHandler = (ctx: SocketContext) => Promise<void> | void;
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
- on(event: string, handler: SocketHandler): this;
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
- constructor(options?: {
22
- port?: number;
23
- router?: SocketRouter;
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
- broadcast(event: string, payload: any): void;
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
- if (server.upgrade(req)) {
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", { id: ws.remoteAddress });
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
- if (this.runningServer) {
70
- this.runningServer.publish("global", JSON.stringify({ event, payload }));
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
- if (this.runningServer) {
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: (evt, data) => this.runningServer.publish(evt, JSON.stringify(data))
94
- // Publish to topic/channel logic needed? Or just broadcast
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.1.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.0"
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 { ServerWebSocket } from 'bun';
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: any;
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: { port?: number; router?: SocketRouter } = {}) {
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
- // Upgrade logic
29
- if (server.upgrade(req)) {
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("Upgrade failed", { status: 500 });
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', { id: ws.remoteAddress });
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
- public broadcast(event: string, payload: any) {
52
- if (this.runningServer) {
53
- this.runningServer.publish('global', JSON.stringify({ event, payload }));
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
- if (this.runningServer) {
59
- this.runningServer.stop();
60
- }
120
+ this.runningServer?.stop();
61
121
  this.app?.logger.info('SocketDriver stopped');
62
122
  }
63
123
 
64
- private async handleMessage(ws: ServerWebSocket<any>, message: string | Buffer) {
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: (evt, data) => this.runningServer.publish(evt, JSON.stringify(data)) // Publish to topic/channel logic needed? Or just 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
- // 2. Fallback to Global App Event
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
- export interface SocketContext<T = any> extends Context<T> {
5
- socket: ServerWebSocket<any>;
6
- broadcast(event: string, payload: any): void;
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 type SocketHandler = (ctx: SocketContext) => Promise<void> | void;
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
- on(event: string, handler: SocketHandler) {
15
- this.handlers.set(event, handler);
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