@rvncom/socket-bun-engine 1.0.0 → 1.0.2

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,5 +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)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@rvncom/socket-bun-engine.svg)](https://www.npmjs.com/package/@rvncom/socket-bun-engine)
5
+
3
6
  Engine.IO server implementation for the Bun runtime. Provides native WebSocket and HTTP long-polling transports for [Socket.IO](https://socket.io/).
4
7
 
5
8
  Fork of `@socket.io/bun-engine` with bug fixes, improved API, and active maintenance.
@@ -7,13 +10,13 @@ Fork of `@socket.io/bun-engine` with bug fixes, improved API, and active mainten
7
10
  ## Installation
8
11
 
9
12
  ```bash
10
- bun add @rvn/bun-engine
13
+ bun add @rvncom/socket-bun-engine
11
14
  ```
12
15
 
13
16
  ## Usage
14
17
 
15
18
  ```ts
16
- import { Server as Engine } from "@rvn/bun-engine";
19
+ import { Server as Engine } from "@rvncom/socket-bun-engine";
17
20
  import { Server } from "socket.io";
18
21
 
19
22
  const engine = new Engine({
@@ -93,6 +96,12 @@ Default: `0` (unlimited)
93
96
 
94
97
  Maximum number of concurrent clients. New connections are rejected with HTTP 503 when the limit is reached.
95
98
 
99
+ ### `backpressureThreshold`
100
+
101
+ Default: `1048576` (1 MB)
102
+
103
+ 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
+
96
105
  ### `allowRequest`
97
106
 
98
107
  A function that receives the handshake/upgrade request and can reject it:
@@ -143,6 +152,56 @@ const engine = new Engine({
143
152
  });
144
153
  ```
145
154
 
155
+ ## Metrics
156
+
157
+ Built-in server metrics with zero dependencies:
158
+
159
+ ```ts
160
+ const snapshot = engine.metrics;
161
+ // {
162
+ // connections: 150, // total opened (cumulative)
163
+ // disconnections: 12, // total closed
164
+ // activeConnections: 138, // currently connected
165
+ // upgrades: 130, // polling → websocket
166
+ // bytesReceived: 524288,
167
+ // bytesSent: 1048576,
168
+ // errors: 2,
169
+ // avgRtt: 14 // average round-trip time (ms)
170
+ // }
171
+ ```
172
+
173
+ Per-socket RTT is also available:
174
+
175
+ ```ts
176
+ engine.on("connection", (socket) => {
177
+ socket.on("heartbeat", () => {
178
+ console.log(`RTT: ${socket.rtt}ms`);
179
+ });
180
+ });
181
+ ```
182
+
183
+ ## API
184
+
185
+ ### `server.clientsCount`
186
+
187
+ Number of currently connected clients.
188
+
189
+ ### `server.metrics`
190
+
191
+ Returns a `MetricsSnapshot` object with server-wide counters.
192
+
193
+ ### `server.sockets`
194
+
195
+ Iterator over all connected `Socket` instances.
196
+
197
+ ### `server.getSocket(id)`
198
+
199
+ Look up a specific socket by session ID.
200
+
201
+ ### `server.close()`
202
+
203
+ Returns a `Promise<void>` that resolves when all clients have disconnected.
204
+
146
205
  ## License
147
206
 
148
207
  [MIT](/LICENSE)
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { Server, type ServerOptions } from "./server";
2
+ export { Socket, type CloseReason } from "./socket";
2
3
  export { type RawData } from "./parser";
3
4
  export { type BunWebSocket, type WebSocketData } from "./transports/websocket";
5
+ export { type MetricsSnapshot } from "./metrics";
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
1
  export { Server } from "./server";
2
+ export { Socket } from "./socket";
2
3
  export {} from "./parser";
3
4
  export {} from "./transports/websocket";
5
+ export {} from "./metrics";
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Built-in server metrics — lightweight counters with zero external dependencies.
3
+ */
4
+ export interface MetricsSnapshot {
5
+ connections: number;
6
+ disconnections: number;
7
+ activeConnections: number;
8
+ upgrades: number;
9
+ bytesReceived: number;
10
+ bytesSent: number;
11
+ errors: number;
12
+ avgRtt: number;
13
+ }
14
+ export declare class ServerMetrics {
15
+ connections: number;
16
+ disconnections: number;
17
+ upgrades: number;
18
+ bytesReceived: number;
19
+ bytesSent: number;
20
+ errors: number;
21
+ private rttSum;
22
+ private rttCount;
23
+ onConnection(): void;
24
+ onDisconnection(): void;
25
+ onUpgrade(): void;
26
+ onBytesReceived(bytes: number): void;
27
+ onBytesSent(bytes: number): void;
28
+ onError(): void;
29
+ onRtt(rtt: number): void;
30
+ snapshot(): MetricsSnapshot;
31
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Built-in server metrics — lightweight counters with zero external dependencies.
3
+ */
4
+ export class ServerMetrics {
5
+ connections = 0;
6
+ disconnections = 0;
7
+ upgrades = 0;
8
+ bytesReceived = 0;
9
+ bytesSent = 0;
10
+ errors = 0;
11
+ rttSum = 0;
12
+ rttCount = 0;
13
+ onConnection() {
14
+ this.connections++;
15
+ }
16
+ onDisconnection() {
17
+ this.disconnections++;
18
+ }
19
+ onUpgrade() {
20
+ this.upgrades++;
21
+ }
22
+ onBytesReceived(bytes) {
23
+ this.bytesReceived += bytes;
24
+ }
25
+ onBytesSent(bytes) {
26
+ this.bytesSent += bytes;
27
+ }
28
+ onError() {
29
+ this.errors++;
30
+ }
31
+ onRtt(rtt) {
32
+ this.rttSum += rtt;
33
+ this.rttCount++;
34
+ }
35
+ snapshot() {
36
+ return {
37
+ connections: this.connections,
38
+ disconnections: this.disconnections,
39
+ activeConnections: this.connections - this.disconnections,
40
+ upgrades: this.upgrades,
41
+ bytesReceived: this.bytesReceived,
42
+ bytesSent: this.bytesSent,
43
+ errors: this.errors,
44
+ avgRtt: this.rttCount > 0 ? Math.round(this.rttSum / this.rttCount) : 0,
45
+ };
46
+ }
47
+ }
package/dist/server.d.ts CHANGED
@@ -3,6 +3,7 @@ import { Socket } from "./socket";
3
3
  import { type BunWebSocket, type WebSocketData } from "./transports/websocket";
4
4
  import { type CorsOptions } from "./cors";
5
5
  import type { RawData } from "./parser";
6
+ import { type MetricsSnapshot } from "./metrics";
6
7
  export interface ServerOptions {
7
8
  /**
8
9
  * Name of the request path to handle
@@ -34,6 +35,11 @@ export interface ServerOptions {
34
35
  * @default 0
35
36
  */
36
37
  maxClients: number;
38
+ /**
39
+ * WebSocket send buffer threshold in bytes for backpressure. Set to 0 to disable.
40
+ * @default 1048576 (1 MB)
41
+ */
42
+ backpressureThreshold: number;
37
43
  /**
38
44
  * A function that receives a given handshake or upgrade request as its first parameter,
39
45
  * and can decide whether to continue or not.
@@ -65,7 +71,12 @@ interface ServerReservedEvents {
65
71
  export declare class Server extends EventEmitter<Record<never, never>, Record<never, never>, ServerReservedEvents> {
66
72
  readonly opts: ServerOptions;
67
73
  private clients;
74
+ private _metrics;
68
75
  get clientsCount(): number;
76
+ /**
77
+ * Returns a snapshot of server metrics.
78
+ */
79
+ get metrics(): MetricsSnapshot;
69
80
  constructor(opts?: Partial<ServerOptions>);
70
81
  /**
71
82
  * Handles an HTTP request.
@@ -73,7 +84,7 @@ export declare class Server extends EventEmitter<Record<never, never>, Record<ne
73
84
  * @param req
74
85
  * @param server
75
86
  */
76
- handleRequest(req: Request, server: Bun.Server<WebSocketData>): Promise<Response>;
87
+ handleRequest(req: Request, server: Bun.Server<WebSocketData>, _url?: URL): Promise<Response>;
77
88
  onWebSocketOpen(ws: BunWebSocket): void;
78
89
  onWebSocketMessage(ws: BunWebSocket, message: RawData): void;
79
90
  onWebSocketClose(ws: BunWebSocket, code: number, message: string): void;
@@ -96,9 +107,17 @@ export declare class Server extends EventEmitter<Record<never, never>, Record<ne
96
107
  */
97
108
  private handshake;
98
109
  /**
99
- * Closes all clients.
110
+ * Returns an iterator over all connected sockets.
111
+ */
112
+ get sockets(): IterableIterator<Socket>;
113
+ /**
114
+ * Returns the socket with the given id, if any.
115
+ */
116
+ getSocket(id: string): Socket | undefined;
117
+ /**
118
+ * Closes all clients and returns a Promise that resolves when all are closed.
100
119
  */
101
- close(): void;
120
+ close(): Promise<void>;
102
121
  /**
103
122
  * Creates a request handler.
104
123
  *
package/dist/server.js CHANGED
@@ -5,6 +5,7 @@ import { WS, } from "./transports/websocket";
5
5
  import { addCorsHeaders } from "./cors";
6
6
  import { Transport } from "./transport";
7
7
  import { generateId } from "./util";
8
+ import { ServerMetrics } from "./metrics";
8
9
  import { debuglog } from "node:util";
9
10
  const debug = debuglog("engine.io");
10
11
  const TRANSPORTS = ["polling", "websocket"];
@@ -28,9 +29,16 @@ const ERROR_MESSAGES = new Map([
28
29
  export class Server extends EventEmitter {
29
30
  opts;
30
31
  clients = new Map();
32
+ _metrics = new ServerMetrics();
31
33
  get clientsCount() {
32
34
  return this.clients.size;
33
35
  }
36
+ /**
37
+ * Returns a snapshot of server metrics.
38
+ */
39
+ get metrics() {
40
+ return this._metrics.snapshot();
41
+ }
34
42
  constructor(opts = {}) {
35
43
  super();
36
44
  this.opts = Object.assign({
@@ -40,6 +48,7 @@ export class Server extends EventEmitter {
40
48
  upgradeTimeout: 10000,
41
49
  maxHttpBufferSize: 1e6,
42
50
  maxClients: 0,
51
+ backpressureThreshold: 1_048_576,
43
52
  }, opts);
44
53
  }
45
54
  /**
@@ -48,8 +57,8 @@ export class Server extends EventEmitter {
48
57
  * @param req
49
58
  * @param server
50
59
  */
51
- async handleRequest(req, server) {
52
- const url = new URL(req.url);
60
+ async handleRequest(req, server, _url) {
61
+ const url = _url ?? new URL(req.url);
53
62
  debug(`handling ${req.method} ${req.url}`);
54
63
  const responseHeaders = new Headers();
55
64
  if (this.opts.cors) {
@@ -67,6 +76,7 @@ export class Server extends EventEmitter {
67
76
  catch (err) {
68
77
  const { code, context } = err;
69
78
  const message = ERROR_MESSAGES.get(code);
79
+ this._metrics.onError();
70
80
  this.emitReserved("connection_error", {
71
81
  req,
72
82
  code,
@@ -266,9 +276,31 @@ export class Server extends EventEmitter {
266
276
  },
267
277
  });
268
278
  this.clients.set(id, socket);
279
+ this._metrics.onConnection();
280
+ socket.on("data", (data) => {
281
+ this._metrics.onBytesReceived(typeof data === "string"
282
+ ? data.length
283
+ : data.byteLength);
284
+ });
285
+ socket.on("packetCreate", (packet) => {
286
+ if (packet.data != null) {
287
+ this._metrics.onBytesSent(typeof packet.data === "string"
288
+ ? packet.data.length
289
+ : packet.data.byteLength);
290
+ }
291
+ });
292
+ socket.on("heartbeat", () => {
293
+ if (socket.rtt > 0) {
294
+ this._metrics.onRtt(socket.rtt);
295
+ }
296
+ });
297
+ socket.on("upgrade", () => {
298
+ this._metrics.onUpgrade();
299
+ });
269
300
  socket.once("close", (reason) => {
270
301
  debug(`socket ${id} closed due to ${reason}`);
271
302
  this.clients.delete(id);
303
+ this._metrics.onDisconnection();
272
304
  });
273
305
  if (isUpgrade) {
274
306
  this.emitReserved("connection", socket, req, server);
@@ -279,11 +311,37 @@ export class Server extends EventEmitter {
279
311
  return promise;
280
312
  }
281
313
  /**
282
- * Closes all clients.
314
+ * Returns an iterator over all connected sockets.
315
+ */
316
+ get sockets() {
317
+ return this.clients.values();
318
+ }
319
+ /**
320
+ * Returns the socket with the given id, if any.
321
+ */
322
+ getSocket(id) {
323
+ return this.clients.get(id);
324
+ }
325
+ /**
326
+ * Closes all clients and returns a Promise that resolves when all are closed.
283
327
  */
284
328
  close() {
285
329
  debug("closing all open clients");
286
- this.clients.forEach((client) => client.close());
330
+ if (this.clients.size === 0) {
331
+ return Promise.resolve();
332
+ }
333
+ return new Promise((resolve) => {
334
+ let remaining = this.clients.size;
335
+ const onClose = () => {
336
+ if (--remaining === 0) {
337
+ resolve();
338
+ }
339
+ };
340
+ this.clients.forEach((client) => {
341
+ client.once("close", onClose);
342
+ client.close();
343
+ });
344
+ });
287
345
  }
288
346
  /**
289
347
  * Creates a request handler.
@@ -323,7 +381,7 @@ export class Server extends EventEmitter {
323
381
  fetch: (req, server) => {
324
382
  const url = new URL(req.url);
325
383
  if (url.pathname === this.opts.path) {
326
- return this.handleRequest(req, server);
384
+ return this.handleRequest(req, server, url);
327
385
  }
328
386
  else {
329
387
  return new Response(null, { status: 404 });
package/dist/socket.d.ts CHANGED
@@ -34,6 +34,8 @@ export declare class Socket extends EventEmitter<Record<never, never>, Record<ne
34
34
  private writeBuffer;
35
35
  private pingIntervalTimer?;
36
36
  private pingTimeoutTimer?;
37
+ private _pingSentAt;
38
+ rtt: number;
37
39
  constructor(id: string, opts: ServerOptions, transport: Transport, req: HandshakeRequestReference);
38
40
  /**
39
41
  * Called upon transport considered open.
package/dist/socket.js CHANGED
@@ -18,6 +18,8 @@ export class Socket extends EventEmitter {
18
18
  */
19
19
  pingIntervalTimer;
20
20
  pingTimeoutTimer;
21
+ _pingSentAt = 0;
22
+ rtt = 0;
21
23
  constructor(id, opts, transport, req) {
22
24
  super();
23
25
  this.id = id;
@@ -60,6 +62,10 @@ export class Socket extends EventEmitter {
60
62
  switch (packet.type) {
61
63
  case "pong":
62
64
  debug("got pong");
65
+ if (this._pingSentAt > 0) {
66
+ this.rtt = Date.now() - this._pingSentAt;
67
+ this._pingSentAt = 0;
68
+ }
63
69
  clearTimeout(this.pingTimeoutTimer);
64
70
  this.schedulePing();
65
71
  this.emitReserved("heartbeat");
@@ -96,6 +102,7 @@ export class Socket extends EventEmitter {
96
102
  }
97
103
  this.pingIntervalTimer = setTimeout(() => {
98
104
  debug(`writing ping packet - expecting pong within ${this.opts.pingTimeout} ms`);
105
+ this._pingSentAt = Date.now();
99
106
  this.sendPacket("ping");
100
107
  this.resetPingTimeout();
101
108
  }, this.opts.pingInterval);
@@ -11,12 +11,33 @@ export class WS extends Transport {
11
11
  return [];
12
12
  }
13
13
  send(packets) {
14
- for (const packet of packets) {
15
- const data = Parser.encodePacket(packet, true);
16
- if (this.writable && this.socket?.readyState === WebSocket.OPEN) {
17
- // TODO use ws.cork() once https://github.com/oven-sh/bun/issues/21588 is resolved
18
- this.socket.send(data);
19
- }
14
+ if (!this.writable ||
15
+ !this.socket ||
16
+ this.socket.readyState !== WebSocket.OPEN) {
17
+ return;
18
+ }
19
+ // Backpressure: pause if send buffer is overloaded
20
+ 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
+ if (packets.length === 1) {
27
+ this.socket.send(Parser.encodePacket(packets[0], true));
28
+ }
29
+ else {
30
+ // Batch multiple packets into a single syscall via cork()
31
+ this.socket.cork(() => {
32
+ for (const packet of packets) {
33
+ this.socket.send(Parser.encodePacket(packet, true));
34
+ }
35
+ });
36
+ }
37
+ // Check backpressure after send
38
+ if (threshold > 0 && this.socket.getBufferedAmount() > threshold) {
39
+ debug("backpressure: buffer full after send, pausing writes");
40
+ this.writable = false;
20
41
  }
21
42
  }
22
43
  doClose() {
@@ -29,6 +50,15 @@ export class WS extends Transport {
29
50
  }
30
51
  onMessage(message) {
31
52
  debug("on message");
53
+ // Resume writes if backpressure cleared (client consuming data)
54
+ if (!this.writable && this.socket) {
55
+ const threshold = this.opts.backpressureThreshold;
56
+ if (threshold > 0 && this.socket.getBufferedAmount() <= threshold) {
57
+ debug("backpressure: buffer drained, resuming writes");
58
+ this.writable = true;
59
+ this.emitReserved("drain");
60
+ }
61
+ }
32
62
  this.onPacket(Parser.decodePacket(message));
33
63
  }
34
64
  onCloseEvent(_code, _message) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rvncom/socket-bun-engine",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Engine.IO server implementation for Bun runtime",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -24,13 +24,18 @@
24
24
  "format:fix": "prettier --write \"lib/**/*.ts\" \"test/**/*.ts\""
25
25
  },
26
26
  "devDependencies": {
27
- "@types/bun": "^1.3.0",
27
+ "@types/bun": "^1.3.10",
28
28
  "oxlint": "latest",
29
- "prettier": "^3.6.2",
30
- "socket.io": "^4.8.1"
29
+ "prettier": "^3.8.1",
30
+ "socket.io": "^4.8.3"
31
31
  },
32
32
  "peerDependencies": {
33
- "typescript": "^5"
33
+ "typescript": "^5.9.2"
34
34
  },
35
- "license": "MIT"
35
+ "license": "MIT",
36
+ "homepage": "https://github.com/rvncom/socket-bun-engine#readme",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/rvncom/socket-bun-engine.git"
40
+ }
36
41
  }