@rvncom/socket-bun-engine 1.0.1 → 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 +108 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/metrics.d.ts +31 -0
- package/dist/metrics.js +47 -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 +42 -0
- package/dist/server.js +96 -1
- package/dist/socket.d.ts +4 -0
- package/dist/socket.js +17 -0
- package/dist/transports/websocket.js +26 -8
- package/package.json +5 -5
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
|
|
|
@@ -96,6 +97,50 @@ Default: `0` (unlimited)
|
|
|
96
97
|
|
|
97
98
|
Maximum number of concurrent clients. New connections are rejected with HTTP 503 when the limit is reached.
|
|
98
99
|
|
|
100
|
+
### `backpressureThreshold`
|
|
101
|
+
|
|
102
|
+
Default: `1048576` (1 MB)
|
|
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.
|
|
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
|
+
|
|
99
144
|
### `allowRequest`
|
|
100
145
|
|
|
101
146
|
A function that receives the handshake/upgrade request and can reject it:
|
|
@@ -146,6 +191,68 @@ const engine = new Engine({
|
|
|
146
191
|
});
|
|
147
192
|
```
|
|
148
193
|
|
|
194
|
+
## Metrics
|
|
195
|
+
|
|
196
|
+
Built-in server metrics with zero dependencies:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
const snapshot = engine.metrics;
|
|
200
|
+
// {
|
|
201
|
+
// connections: 150, // total opened (cumulative)
|
|
202
|
+
// disconnections: 12, // total closed
|
|
203
|
+
// activeConnections: 138, // currently connected
|
|
204
|
+
// upgrades: 130, // polling → websocket
|
|
205
|
+
// bytesReceived: 524288,
|
|
206
|
+
// bytesSent: 1048576,
|
|
207
|
+
// errors: 2,
|
|
208
|
+
// avgRtt: 14 // average round-trip time (ms)
|
|
209
|
+
// }
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Per-socket RTT is also available:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
engine.on("connection", (socket) => {
|
|
216
|
+
socket.on("heartbeat", () => {
|
|
217
|
+
console.log(`RTT: ${socket.rtt}ms`);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## API
|
|
223
|
+
|
|
224
|
+
### `server.clientsCount`
|
|
225
|
+
|
|
226
|
+
Number of currently connected clients.
|
|
227
|
+
|
|
228
|
+
### `server.metrics`
|
|
229
|
+
|
|
230
|
+
Returns a `MetricsSnapshot` object with server-wide counters.
|
|
231
|
+
|
|
232
|
+
### `server.sockets`
|
|
233
|
+
|
|
234
|
+
Iterator over all connected `Socket` instances.
|
|
235
|
+
|
|
236
|
+
### `server.getSocket(id)`
|
|
237
|
+
|
|
238
|
+
Look up a specific socket by session ID.
|
|
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
|
+
|
|
252
|
+
### `server.close()`
|
|
253
|
+
|
|
254
|
+
Returns a `Promise<void>` that resolves when all clients have disconnected.
|
|
255
|
+
|
|
149
256
|
## License
|
|
150
257
|
|
|
151
258
|
[MIT](/LICENSE)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +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
|
+
export { type MetricsSnapshot } from "./metrics";
|
|
6
|
+
export { type RateLimitOptions } from "./rate-limiter";
|
package/dist/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/metrics.js
ADDED
|
@@ -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/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
|
@@ -3,6 +3,8 @@ 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";
|
|
7
|
+
import { type RateLimitOptions } from "./rate-limiter";
|
|
6
8
|
export interface ServerOptions {
|
|
7
9
|
/**
|
|
8
10
|
* Name of the request path to handle
|
|
@@ -34,6 +36,21 @@ export interface ServerOptions {
|
|
|
34
36
|
* @default 0
|
|
35
37
|
*/
|
|
36
38
|
maxClients: number;
|
|
39
|
+
/**
|
|
40
|
+
* WebSocket send buffer threshold in bytes for backpressure. Set to 0 to disable.
|
|
41
|
+
* @default 1048576 (1 MB)
|
|
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;
|
|
37
54
|
/**
|
|
38
55
|
* A function that receives a given handshake or upgrade request as its first parameter,
|
|
39
56
|
* and can decide whether to continue or not.
|
|
@@ -58,14 +75,25 @@ interface ConnectionError {
|
|
|
58
75
|
message: string;
|
|
59
76
|
context: Record<string, unknown>;
|
|
60
77
|
}
|
|
78
|
+
export interface DegradationEvent {
|
|
79
|
+
active: boolean;
|
|
80
|
+
clients: number;
|
|
81
|
+
}
|
|
61
82
|
interface ServerReservedEvents {
|
|
62
83
|
connection: (socket: Socket, request: Request, server: Bun.Server<WebSocketData>) => void;
|
|
63
84
|
connection_error: (err: ConnectionError) => void;
|
|
85
|
+
degradation: (event: DegradationEvent) => void;
|
|
64
86
|
}
|
|
65
87
|
export declare class Server extends EventEmitter<Record<never, never>, Record<never, never>, ServerReservedEvents> {
|
|
66
88
|
readonly opts: ServerOptions;
|
|
67
89
|
private clients;
|
|
90
|
+
private _metrics;
|
|
91
|
+
private _degraded;
|
|
68
92
|
get clientsCount(): number;
|
|
93
|
+
/**
|
|
94
|
+
* Returns a snapshot of server metrics.
|
|
95
|
+
*/
|
|
96
|
+
get metrics(): MetricsSnapshot;
|
|
69
97
|
constructor(opts?: Partial<ServerOptions>);
|
|
70
98
|
/**
|
|
71
99
|
* Handles an HTTP request.
|
|
@@ -103,6 +131,20 @@ export declare class Server extends EventEmitter<Record<never, never>, Record<ne
|
|
|
103
131
|
* Returns the socket with the given id, if any.
|
|
104
132
|
*/
|
|
105
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;
|
|
106
148
|
/**
|
|
107
149
|
* Closes all clients and returns a Promise that resolves when all are closed.
|
|
108
150
|
*/
|
package/dist/server.js
CHANGED
|
@@ -5,6 +5,8 @@ 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";
|
|
9
|
+
import {} from "./rate-limiter";
|
|
8
10
|
import { debuglog } from "node:util";
|
|
9
11
|
const debug = debuglog("engine.io");
|
|
10
12
|
const TRANSPORTS = ["polling", "websocket"];
|
|
@@ -28,9 +30,17 @@ const ERROR_MESSAGES = new Map([
|
|
|
28
30
|
export class Server extends EventEmitter {
|
|
29
31
|
opts;
|
|
30
32
|
clients = new Map();
|
|
33
|
+
_metrics = new ServerMetrics();
|
|
34
|
+
_degraded = false;
|
|
31
35
|
get clientsCount() {
|
|
32
36
|
return this.clients.size;
|
|
33
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Returns a snapshot of server metrics.
|
|
40
|
+
*/
|
|
41
|
+
get metrics() {
|
|
42
|
+
return this._metrics.snapshot();
|
|
43
|
+
}
|
|
34
44
|
constructor(opts = {}) {
|
|
35
45
|
super();
|
|
36
46
|
this.opts = Object.assign({
|
|
@@ -40,6 +50,8 @@ export class Server extends EventEmitter {
|
|
|
40
50
|
upgradeTimeout: 10000,
|
|
41
51
|
maxHttpBufferSize: 1e6,
|
|
42
52
|
maxClients: 0,
|
|
53
|
+
backpressureThreshold: 1_048_576,
|
|
54
|
+
degradationThreshold: 0,
|
|
43
55
|
}, opts);
|
|
44
56
|
}
|
|
45
57
|
/**
|
|
@@ -67,6 +79,7 @@ export class Server extends EventEmitter {
|
|
|
67
79
|
catch (err) {
|
|
68
80
|
const { code, context } = err;
|
|
69
81
|
const message = ERROR_MESSAGES.get(code);
|
|
82
|
+
this._metrics.onError();
|
|
70
83
|
this.emitReserved("connection_error", {
|
|
71
84
|
req,
|
|
72
85
|
code,
|
|
@@ -236,10 +249,27 @@ export class Server extends EventEmitter {
|
|
|
236
249
|
}), { status: 503, headers: responseHeaders });
|
|
237
250
|
}
|
|
238
251
|
const id = generateId();
|
|
252
|
+
// Graceful degradation check
|
|
253
|
+
const degraded = this.isDegraded();
|
|
254
|
+
this.updateDegradationState(degraded);
|
|
239
255
|
if (this.opts.editHandshakeHeaders) {
|
|
240
256
|
await this.opts.editHandshakeHeaders(responseHeaders, req, server);
|
|
241
257
|
}
|
|
242
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
|
+
}
|
|
243
273
|
let transport;
|
|
244
274
|
if (isUpgrade) {
|
|
245
275
|
transport = new WS(this.opts);
|
|
@@ -257,7 +287,10 @@ export class Server extends EventEmitter {
|
|
|
257
287
|
transport = new Polling(this.opts);
|
|
258
288
|
}
|
|
259
289
|
debug(`new socket ${id}`);
|
|
260
|
-
const
|
|
290
|
+
const socketOpts = degraded
|
|
291
|
+
? { ...this.opts, pingInterval: this.opts.pingInterval * 2 }
|
|
292
|
+
: this.opts;
|
|
293
|
+
const socket = new Socket(id, socketOpts, transport, {
|
|
261
294
|
url: req.url,
|
|
262
295
|
headers: Object.fromEntries(req.headers.entries()),
|
|
263
296
|
_query: Object.fromEntries(url.searchParams.entries()),
|
|
@@ -266,9 +299,32 @@ export class Server extends EventEmitter {
|
|
|
266
299
|
},
|
|
267
300
|
});
|
|
268
301
|
this.clients.set(id, socket);
|
|
302
|
+
this._metrics.onConnection();
|
|
303
|
+
socket.on("data", (data) => {
|
|
304
|
+
this._metrics.onBytesReceived(typeof data === "string"
|
|
305
|
+
? data.length
|
|
306
|
+
: data.byteLength);
|
|
307
|
+
});
|
|
308
|
+
socket.on("packetCreate", (packet) => {
|
|
309
|
+
if (packet.data != null) {
|
|
310
|
+
this._metrics.onBytesSent(typeof packet.data === "string"
|
|
311
|
+
? packet.data.length
|
|
312
|
+
: packet.data.byteLength);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
socket.on("heartbeat", () => {
|
|
316
|
+
if (socket.rtt > 0) {
|
|
317
|
+
this._metrics.onRtt(socket.rtt);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
socket.on("upgrade", () => {
|
|
321
|
+
this._metrics.onUpgrade();
|
|
322
|
+
});
|
|
269
323
|
socket.once("close", (reason) => {
|
|
270
324
|
debug(`socket ${id} closed due to ${reason}`);
|
|
271
325
|
this.clients.delete(id);
|
|
326
|
+
this._metrics.onDisconnection();
|
|
327
|
+
this.updateDegradationState(this.isDegraded());
|
|
272
328
|
});
|
|
273
329
|
if (isUpgrade) {
|
|
274
330
|
this.emitReserved("connection", socket, req, server);
|
|
@@ -290,6 +346,45 @@ export class Server extends EventEmitter {
|
|
|
290
346
|
getSocket(id) {
|
|
291
347
|
return this.clients.get(id);
|
|
292
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
|
+
}
|
|
293
388
|
/**
|
|
294
389
|
* Closes all clients and returns a Promise that resolves when all are closed.
|
|
295
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;
|
|
@@ -34,6 +35,9 @@ export declare class Socket extends EventEmitter<Record<never, never>, Record<ne
|
|
|
34
35
|
private writeBuffer;
|
|
35
36
|
private pingIntervalTimer?;
|
|
36
37
|
private pingTimeoutTimer?;
|
|
38
|
+
private _pingSentAt;
|
|
39
|
+
private rateLimiter?;
|
|
40
|
+
rtt: number;
|
|
37
41
|
constructor(id: string, opts: ServerOptions, transport: Transport, req: HandshakeRequestReference);
|
|
38
42
|
/**
|
|
39
43
|
* Called upon transport considered open.
|
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;
|
|
@@ -18,6 +19,9 @@ export class Socket extends EventEmitter {
|
|
|
18
19
|
*/
|
|
19
20
|
pingIntervalTimer;
|
|
20
21
|
pingTimeoutTimer;
|
|
22
|
+
_pingSentAt = 0;
|
|
23
|
+
rateLimiter;
|
|
24
|
+
rtt = 0;
|
|
21
25
|
constructor(id, opts, transport, req) {
|
|
22
26
|
super();
|
|
23
27
|
this.id = id;
|
|
@@ -25,6 +29,9 @@ export class Socket extends EventEmitter {
|
|
|
25
29
|
this.transport = transport;
|
|
26
30
|
this.bindTransport(transport);
|
|
27
31
|
this.request = req;
|
|
32
|
+
if (opts.rateLimit) {
|
|
33
|
+
this.rateLimiter = new RateLimiter(opts.rateLimit);
|
|
34
|
+
}
|
|
28
35
|
this.onOpen();
|
|
29
36
|
}
|
|
30
37
|
/**
|
|
@@ -60,11 +67,20 @@ export class Socket extends EventEmitter {
|
|
|
60
67
|
switch (packet.type) {
|
|
61
68
|
case "pong":
|
|
62
69
|
debug("got pong");
|
|
70
|
+
if (this._pingSentAt > 0) {
|
|
71
|
+
this.rtt = Date.now() - this._pingSentAt;
|
|
72
|
+
this._pingSentAt = 0;
|
|
73
|
+
}
|
|
63
74
|
clearTimeout(this.pingTimeoutTimer);
|
|
64
75
|
this.schedulePing();
|
|
65
76
|
this.emitReserved("heartbeat");
|
|
66
77
|
break;
|
|
67
78
|
case "message":
|
|
79
|
+
if (this.rateLimiter && !this.rateLimiter.consume()) {
|
|
80
|
+
debug("message dropped: rate limited");
|
|
81
|
+
this.emitReserved("rateLimited");
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
68
84
|
this.emitReserved("data", packet.data);
|
|
69
85
|
break;
|
|
70
86
|
case "error":
|
|
@@ -96,6 +112,7 @@ export class Socket extends EventEmitter {
|
|
|
96
112
|
}
|
|
97
113
|
this.pingIntervalTimer = setTimeout(() => {
|
|
98
114
|
debug(`writing ping packet - expecting pong within ${this.opts.pingTimeout} ms`);
|
|
115
|
+
this._pingSentAt = Date.now();
|
|
99
116
|
this.sendPacket("ping");
|
|
100
117
|
this.resetPingTimeout();
|
|
101
118
|
}, this.opts.pingInterval);
|
|
@@ -11,19 +11,28 @@ export class WS extends Transport {
|
|
|
11
11
|
return [];
|
|
12
12
|
}
|
|
13
13
|
send(packets) {
|
|
14
|
-
if (!this.writable ||
|
|
14
|
+
if (!this.writable ||
|
|
15
|
+
!this.socket ||
|
|
16
|
+
this.socket.readyState !== WebSocket.OPEN) {
|
|
15
17
|
return;
|
|
16
18
|
}
|
|
19
|
+
const threshold = this.opts.backpressureThreshold;
|
|
17
20
|
if (packets.length === 1) {
|
|
18
21
|
this.socket.send(Parser.encodePacket(packets[0], true));
|
|
19
|
-
return;
|
|
20
22
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
else {
|
|
24
|
+
// Batch multiple packets into a single syscall via cork()
|
|
25
|
+
this.socket.cork(() => {
|
|
26
|
+
for (const packet of packets) {
|
|
27
|
+
this.socket.send(Parser.encodePacket(packet, true));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// Check backpressure after send
|
|
32
|
+
if (threshold > 0 && this.socket.getBufferedAmount() > threshold) {
|
|
33
|
+
debug("backpressure: buffer full after send, pausing writes");
|
|
34
|
+
this.writable = false;
|
|
35
|
+
}
|
|
27
36
|
}
|
|
28
37
|
doClose() {
|
|
29
38
|
this.socket?.close();
|
|
@@ -35,6 +44,15 @@ export class WS extends Transport {
|
|
|
35
44
|
}
|
|
36
45
|
onMessage(message) {
|
|
37
46
|
debug("on message");
|
|
47
|
+
// Resume writes if backpressure cleared (client consuming data)
|
|
48
|
+
if (!this.writable && this.socket) {
|
|
49
|
+
const threshold = this.opts.backpressureThreshold;
|
|
50
|
+
if (threshold > 0 && this.socket.getBufferedAmount() <= threshold) {
|
|
51
|
+
debug("backpressure: buffer drained, resuming writes");
|
|
52
|
+
this.writable = true;
|
|
53
|
+
this.emitReserved("drain");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
38
56
|
this.onPacket(Parser.decodePacket(message));
|
|
39
57
|
}
|
|
40
58
|
onCloseEvent(_code, _message) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rvncom/socket-bun-engine",
|
|
3
|
-
"version": "1.0.
|
|
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",
|
|
@@ -24,13 +24,13 @@
|
|
|
24
24
|
"format:fix": "prettier --write \"lib/**/*.ts\" \"test/**/*.ts\""
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@types/bun": "^1.3.
|
|
27
|
+
"@types/bun": "^1.3.10",
|
|
28
28
|
"oxlint": "latest",
|
|
29
|
-
"prettier": "^3.
|
|
30
|
-
"socket.io": "^4.8.
|
|
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
35
|
"license": "MIT",
|
|
36
36
|
"homepage": "https://github.com/rvncom/socket-bun-engine#readme",
|