@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 +52 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/parser.d.ts +2 -4
- package/dist/parser.js +3 -3
- package/dist/rate-limiter.d.ts +23 -0
- package/dist/rate-limiter.js +28 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +64 -1
- package/dist/socket.d.ts +2 -0
- package/dist/socket.js +10 -0
- package/dist/transports/websocket.js +0 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# @rvncom/socket-bun-engine
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@rvncom/socket-bun-engine)
|
|
4
4
|
[](https://www.npmjs.com/package/@rvncom/socket-bun-engine)
|
|
5
|
+
[](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
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
|
|
9
|
+
decodePacket(encodedPacket: RawData): Packet;
|
|
11
10
|
encodePayload(packets: Packet[]): string;
|
|
12
|
-
decodePayload(encodedPayload: string
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
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
|
}
|