@rvncom/socket-bun-engine 1.0.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.
@@ -0,0 +1,146 @@
1
+ import { EventEmitter } from "./event-emitter";
2
+ import { Socket } from "./socket";
3
+ import { type BunWebSocket, type WebSocketData } from "./transports/websocket";
4
+ import { type CorsOptions } from "./cors";
5
+ import type { RawData } from "./parser";
6
+ export interface ServerOptions {
7
+ /**
8
+ * Name of the request path to handle
9
+ * @default "/engine.io/"
10
+ */
11
+ path: string;
12
+ /**
13
+ * Duration in milliseconds without a pong packet to consider the connection closed
14
+ * @default 20000
15
+ */
16
+ pingTimeout: number;
17
+ /**
18
+ * Duration in milliseconds before sending a new ping packet
19
+ * @default 25000
20
+ */
21
+ pingInterval: number;
22
+ /**
23
+ * Duration in milliseconds before an uncompleted transport upgrade is cancelled
24
+ * @default 10000
25
+ */
26
+ upgradeTimeout: number;
27
+ /**
28
+ * Maximum size in bytes or number of characters a message can be, before closing the session (to avoid DoS).
29
+ * @default 1e6 (1 MB)
30
+ */
31
+ maxHttpBufferSize: number;
32
+ /**
33
+ * Maximum number of concurrent clients. Set to 0 for unlimited.
34
+ * @default 0
35
+ */
36
+ maxClients: number;
37
+ /**
38
+ * A function that receives a given handshake or upgrade request as its first parameter,
39
+ * and can decide whether to continue or not.
40
+ */
41
+ allowRequest?: (req: Request, server: Bun.Server<WebSocketData>) => Promise<void>;
42
+ /**
43
+ * The options related to Cross-Origin Resource Sharing (CORS)
44
+ */
45
+ cors?: CorsOptions;
46
+ /**
47
+ * A function that allows to edit the response headers of the handshake request
48
+ */
49
+ editHandshakeHeaders?: (responseHeaders: Headers, req: Request, server: Bun.Server<WebSocketData>) => void | Promise<void>;
50
+ /**
51
+ * A function that allows to edit the response headers of all requests
52
+ */
53
+ editResponseHeaders?: (responseHeaders: Headers, req: Request, server: Bun.Server<WebSocketData>) => void | Promise<void>;
54
+ }
55
+ interface ConnectionError {
56
+ req: Request;
57
+ code: number;
58
+ message: string;
59
+ context: Record<string, unknown>;
60
+ }
61
+ interface ServerReservedEvents {
62
+ connection: (socket: Socket, request: Request, server: Bun.Server<WebSocketData>) => void;
63
+ connection_error: (err: ConnectionError) => void;
64
+ }
65
+ export declare class Server extends EventEmitter<Record<never, never>, Record<never, never>, ServerReservedEvents> {
66
+ readonly opts: ServerOptions;
67
+ private clients;
68
+ get clientsCount(): number;
69
+ constructor(opts?: Partial<ServerOptions>);
70
+ /**
71
+ * Handles an HTTP request.
72
+ *
73
+ * @param req
74
+ * @param server
75
+ */
76
+ handleRequest(req: Request, server: Bun.Server<WebSocketData>): Promise<Response>;
77
+ onWebSocketOpen(ws: BunWebSocket): void;
78
+ onWebSocketMessage(ws: BunWebSocket, message: RawData): void;
79
+ onWebSocketClose(ws: BunWebSocket, code: number, message: string): void;
80
+ /**
81
+ * Verifies a request.
82
+ *
83
+ * @param req
84
+ * @param url
85
+ * @private
86
+ */
87
+ private verify;
88
+ /**
89
+ * Handshakes a new client.
90
+ *
91
+ * @param req
92
+ * @param server
93
+ * @param url
94
+ * @param responseHeaders
95
+ * @private
96
+ */
97
+ private handshake;
98
+ /**
99
+ * Closes all clients.
100
+ */
101
+ close(): void;
102
+ /**
103
+ * Creates a request handler.
104
+ *
105
+ * @example
106
+ * Bun.serve({
107
+ * port: 3000,
108
+ * ...engine.handler()
109
+ * });
110
+ *
111
+ * // expanded
112
+ * Bun.serve({
113
+ * port: 3000,
114
+ *
115
+ * fetch(req, server) {
116
+ * return engine.handleRequest(req, server);
117
+ * },
118
+ *
119
+ * websocket: {
120
+ * open(ws: BunWebSocket) {
121
+ * engine.onWebSocketOpen(ws);
122
+ * },
123
+ *
124
+ * message(ws: BunWebSocket, message: RawData) {
125
+ * engine.onWebSocketMessage(ws, message);
126
+ * },
127
+ *
128
+ * close(ws: BunWebSocket, code: number, message: string) {
129
+ * engine.onWebSocketClose(ws, code, message);
130
+ * },
131
+ * },
132
+ * });
133
+ */
134
+ handler(): {
135
+ fetch: (req: Request, server: Bun.Server<WebSocketData>) => Response | Promise<Response>;
136
+ websocket: {
137
+ open: (ws: BunWebSocket) => void;
138
+ message: (ws: BunWebSocket, message: RawData) => void;
139
+ close: (ws: BunWebSocket, code: number, message: string) => void;
140
+ maxPayloadLength: number;
141
+ };
142
+ idleTimeout: number;
143
+ maxRequestBodySize: number;
144
+ };
145
+ }
146
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,348 @@
1
+ import { EventEmitter } from "./event-emitter";
2
+ import { Socket } from "./socket";
3
+ import { Polling } from "./transports/polling";
4
+ import { WS, } from "./transports/websocket";
5
+ import { addCorsHeaders } from "./cors";
6
+ import { Transport } from "./transport";
7
+ import { generateId } from "./util";
8
+ import { debuglog } from "node:util";
9
+ const debug = debuglog("engine.io");
10
+ const TRANSPORTS = ["polling", "websocket"];
11
+ var ERROR_CODES;
12
+ (function (ERROR_CODES) {
13
+ ERROR_CODES[ERROR_CODES["UNKNOWN_TRANSPORT"] = 0] = "UNKNOWN_TRANSPORT";
14
+ ERROR_CODES[ERROR_CODES["UNKNOWN_SID"] = 1] = "UNKNOWN_SID";
15
+ ERROR_CODES[ERROR_CODES["BAD_HANDSHAKE_METHOD"] = 2] = "BAD_HANDSHAKE_METHOD";
16
+ ERROR_CODES[ERROR_CODES["BAD_REQUEST"] = 3] = "BAD_REQUEST";
17
+ ERROR_CODES[ERROR_CODES["FORBIDDEN"] = 4] = "FORBIDDEN";
18
+ ERROR_CODES[ERROR_CODES["UNSUPPORTED_PROTOCOL_VERSION"] = 5] = "UNSUPPORTED_PROTOCOL_VERSION";
19
+ })(ERROR_CODES || (ERROR_CODES = {}));
20
+ const ERROR_MESSAGES = new Map([
21
+ [ERROR_CODES.UNKNOWN_TRANSPORT, "Transport unknown"],
22
+ [ERROR_CODES.UNKNOWN_SID, "Session ID unknown"],
23
+ [ERROR_CODES.BAD_HANDSHAKE_METHOD, "Bad handshake method"],
24
+ [ERROR_CODES.BAD_REQUEST, "Bad request"],
25
+ [ERROR_CODES.FORBIDDEN, "Forbidden"],
26
+ [ERROR_CODES.UNSUPPORTED_PROTOCOL_VERSION, "Unsupported protocol version"],
27
+ ]);
28
+ export class Server extends EventEmitter {
29
+ opts;
30
+ clients = new Map();
31
+ get clientsCount() {
32
+ return this.clients.size;
33
+ }
34
+ constructor(opts = {}) {
35
+ super();
36
+ this.opts = Object.assign({
37
+ path: "/engine.io/",
38
+ pingTimeout: 20000,
39
+ pingInterval: 25000,
40
+ upgradeTimeout: 10000,
41
+ maxHttpBufferSize: 1e6,
42
+ maxClients: 0,
43
+ }, opts);
44
+ }
45
+ /**
46
+ * Handles an HTTP request.
47
+ *
48
+ * @param req
49
+ * @param server
50
+ */
51
+ async handleRequest(req, server) {
52
+ const url = new URL(req.url);
53
+ debug(`handling ${req.method} ${req.url}`);
54
+ const responseHeaders = new Headers();
55
+ if (this.opts.cors) {
56
+ addCorsHeaders(responseHeaders, this.opts.cors, req);
57
+ if (req.method === "OPTIONS") {
58
+ return new Response(null, { status: 204, headers: responseHeaders });
59
+ }
60
+ }
61
+ if (this.opts.editResponseHeaders) {
62
+ await this.opts.editResponseHeaders(responseHeaders, req, server);
63
+ }
64
+ try {
65
+ await this.verify(req, url);
66
+ }
67
+ catch (err) {
68
+ const { code, context } = err;
69
+ const message = ERROR_MESSAGES.get(code);
70
+ this.emitReserved("connection_error", {
71
+ req,
72
+ code,
73
+ message,
74
+ context,
75
+ });
76
+ const body = JSON.stringify({
77
+ code,
78
+ message,
79
+ });
80
+ responseHeaders.set("Content-Type", "application/json");
81
+ return new Response(body, {
82
+ status: 400,
83
+ headers: responseHeaders,
84
+ });
85
+ }
86
+ if (this.opts.allowRequest) {
87
+ try {
88
+ await this.opts.allowRequest(req, server);
89
+ }
90
+ catch (reason) {
91
+ this.emitReserved("connection_error", {
92
+ req,
93
+ code: ERROR_CODES.FORBIDDEN,
94
+ message: ERROR_MESSAGES.get(ERROR_CODES.FORBIDDEN),
95
+ context: {
96
+ message: reason,
97
+ },
98
+ });
99
+ const body = JSON.stringify({
100
+ code: ERROR_CODES.FORBIDDEN,
101
+ message: reason,
102
+ });
103
+ responseHeaders.set("Content-Type", "application/json");
104
+ return new Response(body, {
105
+ status: 403,
106
+ headers: responseHeaders,
107
+ });
108
+ }
109
+ }
110
+ const sid = url.searchParams.get("sid");
111
+ if (sid) {
112
+ // the client must exist since we have checked it in the verify method
113
+ const socket = this.clients.get(sid);
114
+ if (req.headers.has("upgrade")) {
115
+ const transport = new WS(this.opts);
116
+ const isSuccess = server.upgrade(req, {
117
+ headers: responseHeaders,
118
+ data: {
119
+ transport,
120
+ },
121
+ });
122
+ debug(`upgrade was successful: ${isSuccess}`);
123
+ if (!isSuccess) {
124
+ return new Response(null, { status: 500 });
125
+ }
126
+ socket._maybeUpgrade(transport);
127
+ return new Response(null);
128
+ }
129
+ debug("setting new request for existing socket");
130
+ return socket.transport.onRequest(req, responseHeaders);
131
+ }
132
+ else {
133
+ return this.handshake(req, server, url, responseHeaders);
134
+ }
135
+ }
136
+ onWebSocketOpen(ws) {
137
+ debug("on ws open");
138
+ ws.data.transport.onOpen(ws);
139
+ }
140
+ onWebSocketMessage(ws, message) {
141
+ debug("on ws message");
142
+ ws.data.transport.onMessage(message);
143
+ }
144
+ onWebSocketClose(ws, code, message) {
145
+ debug("on ws close");
146
+ ws.data.transport.onCloseEvent(code, message);
147
+ }
148
+ /**
149
+ * Verifies a request.
150
+ *
151
+ * @param req
152
+ * @param url
153
+ * @private
154
+ */
155
+ verify(req, url) {
156
+ const transport = url.searchParams.get("transport") || "";
157
+ if (!TRANSPORTS.includes(transport)) {
158
+ debug(`unknown transport "${transport}"`);
159
+ return Promise.reject({
160
+ code: ERROR_CODES.UNKNOWN_TRANSPORT,
161
+ context: {
162
+ transport,
163
+ },
164
+ });
165
+ }
166
+ const sid = url.searchParams.get("sid");
167
+ if (sid) {
168
+ const client = this.clients.get(sid);
169
+ if (!client) {
170
+ debug(`unknown client with sid ${sid}`);
171
+ return Promise.reject({
172
+ code: ERROR_CODES.UNKNOWN_SID,
173
+ context: {
174
+ sid,
175
+ },
176
+ });
177
+ }
178
+ const previousTransport = client.transport.name;
179
+ if (previousTransport === "websocket") {
180
+ debug("unexpected transport without upgrade");
181
+ return Promise.reject({
182
+ code: ERROR_CODES.BAD_REQUEST,
183
+ context: {
184
+ name: "TRANSPORT_MISMATCH",
185
+ transport,
186
+ previousTransport,
187
+ },
188
+ });
189
+ }
190
+ }
191
+ else {
192
+ // handshake is GET only
193
+ if (req.method !== "GET") {
194
+ return Promise.reject({
195
+ code: ERROR_CODES.BAD_HANDSHAKE_METHOD,
196
+ context: {
197
+ method: req.method,
198
+ },
199
+ });
200
+ }
201
+ const protocol = url.searchParams.get("EIO") === "4" ? 4 : 3; // 3rd revision by default
202
+ if (protocol === 3) {
203
+ return Promise.reject({
204
+ code: ERROR_CODES.UNSUPPORTED_PROTOCOL_VERSION,
205
+ context: {
206
+ protocol,
207
+ },
208
+ });
209
+ }
210
+ }
211
+ return Promise.resolve();
212
+ }
213
+ /**
214
+ * Handshakes a new client.
215
+ *
216
+ * @param req
217
+ * @param server
218
+ * @param url
219
+ * @param responseHeaders
220
+ * @private
221
+ */
222
+ async handshake(req, server, url, responseHeaders) {
223
+ if (this.opts.maxClients > 0 && this.clients.size >= this.opts.maxClients) {
224
+ this.emitReserved("connection_error", {
225
+ req,
226
+ code: ERROR_CODES.FORBIDDEN,
227
+ message: "Server capacity reached",
228
+ context: {
229
+ maxClients: this.opts.maxClients,
230
+ },
231
+ });
232
+ responseHeaders.set("Content-Type", "application/json");
233
+ return new Response(JSON.stringify({
234
+ code: ERROR_CODES.FORBIDDEN,
235
+ message: "Server capacity reached",
236
+ }), { status: 503, headers: responseHeaders });
237
+ }
238
+ const id = generateId();
239
+ if (this.opts.editHandshakeHeaders) {
240
+ await this.opts.editHandshakeHeaders(responseHeaders, req, server);
241
+ }
242
+ let isUpgrade = req.headers.has("upgrade");
243
+ let transport;
244
+ if (isUpgrade) {
245
+ transport = new WS(this.opts);
246
+ const isSuccess = server.upgrade(req, {
247
+ headers: responseHeaders,
248
+ data: {
249
+ transport: transport,
250
+ },
251
+ });
252
+ if (!isSuccess) {
253
+ return new Response(null, { status: 500 });
254
+ }
255
+ }
256
+ else {
257
+ transport = new Polling(this.opts);
258
+ }
259
+ debug(`new socket ${id}`);
260
+ const socket = new Socket(id, this.opts, transport, {
261
+ url: req.url,
262
+ headers: Object.fromEntries(req.headers.entries()),
263
+ _query: Object.fromEntries(url.searchParams.entries()),
264
+ connection: {
265
+ encrypted: ["https", "wss"].includes(url.protocol),
266
+ },
267
+ });
268
+ this.clients.set(id, socket);
269
+ socket.once("close", (reason) => {
270
+ debug(`socket ${id} closed due to ${reason}`);
271
+ this.clients.delete(id);
272
+ });
273
+ if (isUpgrade) {
274
+ this.emitReserved("connection", socket, req, server);
275
+ return new Response(null);
276
+ }
277
+ const promise = transport.onRequest(req, responseHeaders);
278
+ this.emitReserved("connection", socket, req, server);
279
+ return promise;
280
+ }
281
+ /**
282
+ * Closes all clients.
283
+ */
284
+ close() {
285
+ debug("closing all open clients");
286
+ this.clients.forEach((client) => client.close());
287
+ }
288
+ /**
289
+ * Creates a request handler.
290
+ *
291
+ * @example
292
+ * Bun.serve({
293
+ * port: 3000,
294
+ * ...engine.handler()
295
+ * });
296
+ *
297
+ * // expanded
298
+ * Bun.serve({
299
+ * port: 3000,
300
+ *
301
+ * fetch(req, server) {
302
+ * return engine.handleRequest(req, server);
303
+ * },
304
+ *
305
+ * websocket: {
306
+ * open(ws: BunWebSocket) {
307
+ * engine.onWebSocketOpen(ws);
308
+ * },
309
+ *
310
+ * message(ws: BunWebSocket, message: RawData) {
311
+ * engine.onWebSocketMessage(ws, message);
312
+ * },
313
+ *
314
+ * close(ws: BunWebSocket, code: number, message: string) {
315
+ * engine.onWebSocketClose(ws, code, message);
316
+ * },
317
+ * },
318
+ * });
319
+ */
320
+ handler() {
321
+ const idleTimeoutInSeconds = Math.ceil((2 * this.opts.pingInterval) / 1000);
322
+ return {
323
+ fetch: (req, server) => {
324
+ const url = new URL(req.url);
325
+ if (url.pathname === this.opts.path) {
326
+ return this.handleRequest(req, server);
327
+ }
328
+ else {
329
+ return new Response(null, { status: 404 });
330
+ }
331
+ },
332
+ websocket: {
333
+ open: (ws) => {
334
+ this.onWebSocketOpen(ws);
335
+ },
336
+ message: (ws, message) => {
337
+ this.onWebSocketMessage(ws, message);
338
+ },
339
+ close: (ws, code, message) => {
340
+ this.onWebSocketClose(ws, code, message);
341
+ },
342
+ maxPayloadLength: this.opts.maxHttpBufferSize,
343
+ },
344
+ idleTimeout: idleTimeoutInSeconds,
345
+ maxRequestBodySize: this.opts.maxHttpBufferSize,
346
+ };
347
+ }
348
+ }
@@ -0,0 +1,123 @@
1
+ import { EventEmitter } from "./event-emitter";
2
+ import { type Packet, type RawData } from "./parser";
3
+ import { Transport } from "./transport";
4
+ import { type ServerOptions } from "./server";
5
+ type ReadyState = "opening" | "open" | "closing" | "closed";
6
+ export interface HandshakeRequestReference {
7
+ url: string;
8
+ headers: Record<string, string>;
9
+ _query: Record<string, string>;
10
+ connection: {
11
+ encrypted: boolean;
12
+ };
13
+ }
14
+ export type CloseReason = "transport error" | "transport close" | "forced close" | "ping timeout" | "parse error";
15
+ interface SocketEvents {
16
+ open: () => void;
17
+ packet: (packet: Packet) => void;
18
+ packetCreate: (packet: Packet) => void;
19
+ data: (data: RawData) => void;
20
+ flush: (writeBuffer: Packet[]) => void;
21
+ drain: () => void;
22
+ heartbeat: () => void;
23
+ upgrading: (transport: Transport) => void;
24
+ upgrade: (transport: Transport) => void;
25
+ close: (reason: CloseReason) => void;
26
+ }
27
+ export declare class Socket extends EventEmitter<Record<never, never>, Record<never, never>, SocketEvents> {
28
+ readonly id: string;
29
+ readyState: ReadyState;
30
+ transport: Transport;
31
+ readonly request: HandshakeRequestReference;
32
+ private readonly opts;
33
+ private upgradeState;
34
+ private writeBuffer;
35
+ private pingIntervalTimer?;
36
+ private pingTimeoutTimer?;
37
+ constructor(id: string, opts: ServerOptions, transport: Transport, req: HandshakeRequestReference);
38
+ /**
39
+ * Called upon transport considered open.
40
+ *
41
+ * @private
42
+ */
43
+ private onOpen;
44
+ /**
45
+ * Called upon transport packet.
46
+ *
47
+ * @param packet
48
+ * @private
49
+ */
50
+ private onPacket;
51
+ /**
52
+ * Called upon transport error.
53
+ *
54
+ * @param err
55
+ * @private
56
+ */
57
+ private onError;
58
+ /**
59
+ * Pings client every `pingInterval` and expects response
60
+ * within `pingTimeout` or closes connection.
61
+ *
62
+ * @private
63
+ */
64
+ private schedulePing;
65
+ /**
66
+ * Resets ping timeout.
67
+ *
68
+ * @private
69
+ */
70
+ private resetPingTimeout;
71
+ /**
72
+ * Attaches handlers for the given transport.
73
+ *
74
+ * @param transport
75
+ * @private
76
+ */
77
+ private bindTransport;
78
+ /**
79
+ * Upgrades socket to the given transport
80
+ *
81
+ * @param transport
82
+ * @private
83
+ */
84
+ _maybeUpgrade(transport: Transport): void;
85
+ /**
86
+ * Called upon transport considered closed.
87
+ *
88
+ * @param reason
89
+ * @private
90
+ */
91
+ private onClose;
92
+ /**
93
+ * Sends a "message" packet.
94
+ *
95
+ * @param data
96
+ */
97
+ write(data: RawData): Socket;
98
+ /**
99
+ * Sends a packet.
100
+ *
101
+ * @param type
102
+ * @param data
103
+ * @private
104
+ */
105
+ private sendPacket;
106
+ /**
107
+ * Attempts to flush the packets buffer.
108
+ *
109
+ * @private
110
+ */
111
+ private flush;
112
+ /**
113
+ * Closes the socket and underlying transport.
114
+ */
115
+ close(): void;
116
+ /**
117
+ * Closes the underlying transport.
118
+ *
119
+ * @private
120
+ */
121
+ private closeTransport;
122
+ }
123
+ export {};