@rvncom/socket-bun-engine 1.0.2 → 1.0.3

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,7 +1,8 @@
1
1
  # @rvncom/socket-bun-engine
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@rvncom/socket-bun-engine.svg)](https://www.npmjs.com/package/@rvncom/socket-bun-engine)
3
+ [![npm version](https://img.shields.io/npm/v/@rvncom/socket-bun-engine?style=flat-square&color=blue&label=version)](https://www.npmjs.com/package/@rvncom/socket-bun-engine)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@rvncom/socket-bun-engine.svg)](https://www.npmjs.com/package/@rvncom/socket-bun-engine)
5
+ [![license](https://img.shields.io/npm/l/@rvncom/socket-bun-engine?style=flat-square&color=orange)](https://github.com/rvncom/socket-bun-engine/blob/main/LICENSE)
5
6
 
6
7
  Engine.IO server implementation for the Bun runtime. Provides native WebSocket and HTTP long-polling transports for [Socket.IO](https://socket.io/).
7
8
 
@@ -102,6 +103,44 @@ Default: `1048576` (1 MB)
102
103
 
103
104
  WebSocket send buffer threshold in bytes. When `getBufferedAmount()` exceeds this value, writes are paused automatically and resumed when the buffer drains. Set to `0` to disable.
104
105
 
106
+ ### `rateLimit`
107
+
108
+ Per-socket message rate limiting. Disabled by default.
109
+
110
+ ```ts
111
+ const engine = new Engine({
112
+ rateLimit: {
113
+ maxMessages: 100, // max messages per window
114
+ windowMs: 1000, // window duration in ms
115
+ },
116
+ });
117
+
118
+ engine.on("connection", (socket) => {
119
+ socket.on("rateLimited", () => {
120
+ console.log(`Socket ${socket.id} rate limited`);
121
+ });
122
+ });
123
+ ```
124
+
125
+ ### `degradationThreshold`
126
+
127
+ Default: `0` (disabled)
128
+
129
+ Fraction (0–1) of `maxClients` at which graceful degradation activates. Requires `maxClients > 0`. When active:
130
+ - New polling connections are rejected (WebSocket only, returns 503)
131
+ - New connections get doubled `pingInterval` to reduce heartbeat overhead
132
+
133
+ ```ts
134
+ const engine = new Engine({
135
+ maxClients: 10000,
136
+ degradationThreshold: 0.8, // degrade at 8000+ clients
137
+ });
138
+
139
+ engine.on("degradation", ({ active, clients }) => {
140
+ console.log(`Degradation ${active ? "ON" : "OFF"} at ${clients} clients`);
141
+ });
142
+ ```
143
+
105
144
  ### `allowRequest`
106
145
 
107
146
  A function that receives the handshake/upgrade request and can reject it:
@@ -198,6 +237,18 @@ Iterator over all connected `Socket` instances.
198
237
 
199
238
  Look up a specific socket by session ID.
200
239
 
240
+ ### `server.broadcast(data)`
241
+
242
+ Sends a message to all connected sockets.
243
+
244
+ ### `server.broadcastExcept(excludeId, data)`
245
+
246
+ Sends a message to all connected sockets except the one with the given id.
247
+
248
+ ### `server.degraded`
249
+
250
+ Returns `true` if the server is currently in degraded mode.
251
+
201
252
  ### `server.close()`
202
253
 
203
254
  Returns a `Promise<void>` that resolves when all clients have disconnected.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- export { Server, type ServerOptions } from "./server";
1
+ export { Server, type ServerOptions, type DegradationEvent } from "./server";
2
2
  export { Socket, type CloseReason } from "./socket";
3
3
  export { type RawData } from "./parser";
4
4
  export { type BunWebSocket, type WebSocketData } from "./transports/websocket";
5
5
  export { type MetricsSnapshot } from "./metrics";
6
+ export { type RateLimitOptions } from "./rate-limiter";
package/dist/index.js CHANGED
@@ -3,3 +3,4 @@ export { Socket } from "./socket";
3
3
  export {} from "./parser";
4
4
  export {} from "./transports/websocket";
5
5
  export {} from "./metrics";
6
+ export {} from "./rate-limiter";
package/dist/parser.d.ts CHANGED
@@ -4,11 +4,9 @@ export interface Packet {
4
4
  type: PacketType;
5
5
  data?: RawData;
6
6
  }
7
- type BinaryType = "arraybuffer" | "blob";
8
7
  export declare const Parser: {
9
8
  encodePacket({ type, data }: Packet, supportsBinary: boolean): RawData;
10
- decodePacket(encodedPacket: RawData, _binaryType?: BinaryType): Packet;
9
+ decodePacket(encodedPacket: RawData): Packet;
11
10
  encodePayload(packets: Packet[]): string;
12
- decodePayload(encodedPayload: string, binaryType?: BinaryType): Packet[];
11
+ decodePayload(encodedPayload: string): Packet[];
13
12
  };
14
- export {};
package/dist/parser.js CHANGED
@@ -23,7 +23,7 @@ export const Parser = {
23
23
  return PACKET_TYPES.get(type) + (data || "");
24
24
  }
25
25
  },
26
- decodePacket(encodedPacket, _binaryType) {
26
+ decodePacket(encodedPacket) {
27
27
  if (typeof encodedPacket !== "string") {
28
28
  return {
29
29
  type: "message",
@@ -58,11 +58,11 @@ export const Parser = {
58
58
  }
59
59
  return encodedPackets.join(SEPARATOR);
60
60
  },
61
- decodePayload(encodedPayload, binaryType) {
61
+ decodePayload(encodedPayload) {
62
62
  const encodedPackets = encodedPayload.split(SEPARATOR);
63
63
  const packets = [];
64
64
  for (let i = 0; i < encodedPackets.length; i++) {
65
- const decodedPacket = this.decodePacket(encodedPackets[i], binaryType);
65
+ const decodedPacket = this.decodePacket(encodedPackets[i]);
66
66
  packets.push(decodedPacket);
67
67
  if (decodedPacket.type === "error") {
68
68
  break;
@@ -0,0 +1,23 @@
1
+ export interface RateLimitOptions {
2
+ /**
3
+ * Maximum number of messages allowed per window.
4
+ */
5
+ maxMessages: number;
6
+ /**
7
+ * Time window in milliseconds.
8
+ */
9
+ windowMs: number;
10
+ }
11
+ /**
12
+ * Simple token bucket rate limiter — no timers, lazy window reset.
13
+ */
14
+ export declare class RateLimiter {
15
+ private remaining;
16
+ private windowStart;
17
+ private readonly opts;
18
+ constructor(opts: RateLimitOptions);
19
+ /**
20
+ * Attempt to consume one token. Returns `true` if allowed, `false` if rate limited.
21
+ */
22
+ consume(): boolean;
23
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Simple token bucket rate limiter — no timers, lazy window reset.
3
+ */
4
+ export class RateLimiter {
5
+ remaining;
6
+ windowStart;
7
+ opts;
8
+ constructor(opts) {
9
+ this.opts = opts;
10
+ this.remaining = opts.maxMessages;
11
+ this.windowStart = Date.now();
12
+ }
13
+ /**
14
+ * Attempt to consume one token. Returns `true` if allowed, `false` if rate limited.
15
+ */
16
+ consume() {
17
+ const now = Date.now();
18
+ if (now - this.windowStart >= this.opts.windowMs) {
19
+ this.windowStart = now;
20
+ this.remaining = this.opts.maxMessages;
21
+ }
22
+ if (this.remaining <= 0) {
23
+ return false;
24
+ }
25
+ this.remaining--;
26
+ return true;
27
+ }
28
+ }
package/dist/server.d.ts CHANGED
@@ -4,6 +4,7 @@ import { type BunWebSocket, type WebSocketData } from "./transports/websocket";
4
4
  import { type CorsOptions } from "./cors";
5
5
  import type { RawData } from "./parser";
6
6
  import { type MetricsSnapshot } from "./metrics";
7
+ import { type RateLimitOptions } from "./rate-limiter";
7
8
  export interface ServerOptions {
8
9
  /**
9
10
  * Name of the request path to handle
@@ -40,6 +41,16 @@ export interface ServerOptions {
40
41
  * @default 1048576 (1 MB)
41
42
  */
42
43
  backpressureThreshold: number;
44
+ /**
45
+ * Per-socket message rate limiting. Disabled by default.
46
+ */
47
+ rateLimit?: RateLimitOptions;
48
+ /**
49
+ * Fraction (0–1) of maxClients at which graceful degradation activates.
50
+ * Requires maxClients > 0. Set to 0 to disable (default).
51
+ * @default 0
52
+ */
53
+ degradationThreshold: number;
43
54
  /**
44
55
  * A function that receives a given handshake or upgrade request as its first parameter,
45
56
  * and can decide whether to continue or not.
@@ -64,14 +75,20 @@ interface ConnectionError {
64
75
  message: string;
65
76
  context: Record<string, unknown>;
66
77
  }
78
+ export interface DegradationEvent {
79
+ active: boolean;
80
+ clients: number;
81
+ }
67
82
  interface ServerReservedEvents {
68
83
  connection: (socket: Socket, request: Request, server: Bun.Server<WebSocketData>) => void;
69
84
  connection_error: (err: ConnectionError) => void;
85
+ degradation: (event: DegradationEvent) => void;
70
86
  }
71
87
  export declare class Server extends EventEmitter<Record<never, never>, Record<never, never>, ServerReservedEvents> {
72
88
  readonly opts: ServerOptions;
73
89
  private clients;
74
90
  private _metrics;
91
+ private _degraded;
75
92
  get clientsCount(): number;
76
93
  /**
77
94
  * Returns a snapshot of server metrics.
@@ -114,6 +131,20 @@ export declare class Server extends EventEmitter<Record<never, never>, Record<ne
114
131
  * Returns the socket with the given id, if any.
115
132
  */
116
133
  getSocket(id: string): Socket | undefined;
134
+ /**
135
+ * Returns whether the server is currently in degraded mode.
136
+ */
137
+ get degraded(): boolean;
138
+ /**
139
+ * Sends a message to all connected sockets.
140
+ */
141
+ broadcast(data: RawData): void;
142
+ /**
143
+ * Sends a message to all connected sockets except the one with the given id.
144
+ */
145
+ broadcastExcept(excludeId: string, data: RawData): void;
146
+ private isDegraded;
147
+ private updateDegradationState;
117
148
  /**
118
149
  * Closes all clients and returns a Promise that resolves when all are closed.
119
150
  */
package/dist/server.js CHANGED
@@ -6,6 +6,7 @@ import { addCorsHeaders } from "./cors";
6
6
  import { Transport } from "./transport";
7
7
  import { generateId } from "./util";
8
8
  import { ServerMetrics } from "./metrics";
9
+ import {} from "./rate-limiter";
9
10
  import { debuglog } from "node:util";
10
11
  const debug = debuglog("engine.io");
11
12
  const TRANSPORTS = ["polling", "websocket"];
@@ -30,6 +31,7 @@ export class Server extends EventEmitter {
30
31
  opts;
31
32
  clients = new Map();
32
33
  _metrics = new ServerMetrics();
34
+ _degraded = false;
33
35
  get clientsCount() {
34
36
  return this.clients.size;
35
37
  }
@@ -49,6 +51,7 @@ export class Server extends EventEmitter {
49
51
  maxHttpBufferSize: 1e6,
50
52
  maxClients: 0,
51
53
  backpressureThreshold: 1_048_576,
54
+ degradationThreshold: 0,
52
55
  }, opts);
53
56
  }
54
57
  /**
@@ -246,10 +249,27 @@ export class Server extends EventEmitter {
246
249
  }), { status: 503, headers: responseHeaders });
247
250
  }
248
251
  const id = generateId();
252
+ // Graceful degradation check
253
+ const degraded = this.isDegraded();
254
+ this.updateDegradationState(degraded);
249
255
  if (this.opts.editHandshakeHeaders) {
250
256
  await this.opts.editHandshakeHeaders(responseHeaders, req, server);
251
257
  }
252
258
  let isUpgrade = req.headers.has("upgrade");
259
+ // Under degradation, reject new polling connections (WebSocket only)
260
+ if (degraded && !isUpgrade) {
261
+ this.emitReserved("connection_error", {
262
+ req,
263
+ code: ERROR_CODES.FORBIDDEN,
264
+ message: "Degraded mode: only WebSocket connections accepted",
265
+ context: { degraded: true },
266
+ });
267
+ responseHeaders.set("Content-Type", "application/json");
268
+ return new Response(JSON.stringify({
269
+ code: ERROR_CODES.FORBIDDEN,
270
+ message: "Degraded mode: only WebSocket connections accepted",
271
+ }), { status: 503, headers: responseHeaders });
272
+ }
253
273
  let transport;
254
274
  if (isUpgrade) {
255
275
  transport = new WS(this.opts);
@@ -267,7 +287,10 @@ export class Server extends EventEmitter {
267
287
  transport = new Polling(this.opts);
268
288
  }
269
289
  debug(`new socket ${id}`);
270
- const socket = new Socket(id, this.opts, transport, {
290
+ const socketOpts = degraded
291
+ ? { ...this.opts, pingInterval: this.opts.pingInterval * 2 }
292
+ : this.opts;
293
+ const socket = new Socket(id, socketOpts, transport, {
271
294
  url: req.url,
272
295
  headers: Object.fromEntries(req.headers.entries()),
273
296
  _query: Object.fromEntries(url.searchParams.entries()),
@@ -301,6 +324,7 @@ export class Server extends EventEmitter {
301
324
  debug(`socket ${id} closed due to ${reason}`);
302
325
  this.clients.delete(id);
303
326
  this._metrics.onDisconnection();
327
+ this.updateDegradationState(this.isDegraded());
304
328
  });
305
329
  if (isUpgrade) {
306
330
  this.emitReserved("connection", socket, req, server);
@@ -322,6 +346,45 @@ export class Server extends EventEmitter {
322
346
  getSocket(id) {
323
347
  return this.clients.get(id);
324
348
  }
349
+ /**
350
+ * Returns whether the server is currently in degraded mode.
351
+ */
352
+ get degraded() {
353
+ return this._degraded;
354
+ }
355
+ /**
356
+ * Sends a message to all connected sockets.
357
+ */
358
+ broadcast(data) {
359
+ for (const socket of this.clients.values()) {
360
+ socket.write(data);
361
+ }
362
+ }
363
+ /**
364
+ * Sends a message to all connected sockets except the one with the given id.
365
+ */
366
+ broadcastExcept(excludeId, data) {
367
+ for (const [id, socket] of this.clients) {
368
+ if (id !== excludeId) {
369
+ socket.write(data);
370
+ }
371
+ }
372
+ }
373
+ isDegraded() {
374
+ const { degradationThreshold, maxClients } = this.opts;
375
+ if (degradationThreshold <= 0 || maxClients <= 0)
376
+ return false;
377
+ return this.clients.size / maxClients >= degradationThreshold;
378
+ }
379
+ updateDegradationState(degraded) {
380
+ if (degraded !== this._degraded) {
381
+ this._degraded = degraded;
382
+ this.emitReserved("degradation", {
383
+ active: degraded,
384
+ clients: this.clients.size,
385
+ });
386
+ }
387
+ }
325
388
  /**
326
389
  * Closes all clients and returns a Promise that resolves when all are closed.
327
390
  */
package/dist/socket.d.ts CHANGED
@@ -23,6 +23,7 @@ interface SocketEvents {
23
23
  upgrading: (transport: Transport) => void;
24
24
  upgrade: (transport: Transport) => void;
25
25
  close: (reason: CloseReason) => void;
26
+ rateLimited: () => void;
26
27
  }
27
28
  export declare class Socket extends EventEmitter<Record<never, never>, Record<never, never>, SocketEvents> {
28
29
  readonly id: string;
@@ -35,6 +36,7 @@ export declare class Socket extends EventEmitter<Record<never, never>, Record<ne
35
36
  private pingIntervalTimer?;
36
37
  private pingTimeoutTimer?;
37
38
  private _pingSentAt;
39
+ private rateLimiter?;
38
40
  rtt: number;
39
41
  constructor(id: string, opts: ServerOptions, transport: Transport, req: HandshakeRequestReference);
40
42
  /**
package/dist/socket.js CHANGED
@@ -2,6 +2,7 @@ import { EventEmitter } from "./event-emitter";
2
2
  import {} from "./parser";
3
3
  import { Transport, TransportError } from "./transport";
4
4
  import {} from "./server";
5
+ import { RateLimiter } from "./rate-limiter";
5
6
  import { debuglog } from "node:util";
6
7
  const debug = debuglog("engine.io:socket");
7
8
  const FAST_UPGRADE_INTERVAL_MS = 100;
@@ -19,6 +20,7 @@ export class Socket extends EventEmitter {
19
20
  pingIntervalTimer;
20
21
  pingTimeoutTimer;
21
22
  _pingSentAt = 0;
23
+ rateLimiter;
22
24
  rtt = 0;
23
25
  constructor(id, opts, transport, req) {
24
26
  super();
@@ -27,6 +29,9 @@ export class Socket extends EventEmitter {
27
29
  this.transport = transport;
28
30
  this.bindTransport(transport);
29
31
  this.request = req;
32
+ if (opts.rateLimit) {
33
+ this.rateLimiter = new RateLimiter(opts.rateLimit);
34
+ }
30
35
  this.onOpen();
31
36
  }
32
37
  /**
@@ -71,6 +76,11 @@ export class Socket extends EventEmitter {
71
76
  this.emitReserved("heartbeat");
72
77
  break;
73
78
  case "message":
79
+ if (this.rateLimiter && !this.rateLimiter.consume()) {
80
+ debug("message dropped: rate limited");
81
+ this.emitReserved("rateLimited");
82
+ break;
83
+ }
74
84
  this.emitReserved("data", packet.data);
75
85
  break;
76
86
  case "error":
@@ -16,13 +16,7 @@ export class WS extends Transport {
16
16
  this.socket.readyState !== WebSocket.OPEN) {
17
17
  return;
18
18
  }
19
- // Backpressure: pause if send buffer is overloaded
20
19
  const threshold = this.opts.backpressureThreshold;
21
- if (threshold > 0 && this.socket.getBufferedAmount() > threshold) {
22
- debug("backpressure: send buffer exceeded threshold, pausing writes");
23
- this.writable = false;
24
- return;
25
- }
26
20
  if (packets.length === 1) {
27
21
  this.socket.send(Parser.encodePacket(packets[0], true));
28
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rvncom/socket-bun-engine",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Engine.IO server implementation for Bun runtime",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",