@colyseus/uwebsockets-transport 0.17.1 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colyseus/uwebsockets-transport",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "type": "module",
5
5
  "input": "./src/index.ts",
6
6
  "main": "./build/index.js",
@@ -8,13 +8,15 @@
8
8
  "typings": "./build/index.d.ts",
9
9
  "exports": {
10
10
  ".": {
11
+ "@source": "./src/index.ts",
11
12
  "types": "./build/index.d.ts",
12
- "import": "./src/index.ts",
13
+ "import": "./build/index.mjs",
13
14
  "require": "./build/index.js"
14
15
  },
15
16
  "./*": {
17
+ "@source": "./src/*.ts",
16
18
  "types": "./build/*.d.ts",
17
- "import": "./src/*.ts",
19
+ "import": "./build/*.mjs",
18
20
  "require": "./build/*.js"
19
21
  }
20
22
  },
@@ -23,7 +25,7 @@
23
25
  "uwebsockets-express": "^1.3.8"
24
26
  },
25
27
  "devDependencies": {
26
- "@colyseus/core": "^0.17.0"
28
+ "@colyseus/core": "^0.17.2"
27
29
  },
28
30
  "peerDependencies": {
29
31
  "@colyseus/core": "0.17.x"
@@ -39,8 +41,7 @@
39
41
  ],
40
42
  "files": [
41
43
  "build",
42
- "LICENSE",
43
- "README.md"
44
+ "src"
44
45
  ],
45
46
  "repository": {
46
47
  "type": "git",
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { uWebSocketClient } from './uWebSocketClient.ts';
2
+ export { uWebSocketsTransport, type TransportOptions } from './uWebSocketsTransport.ts'
@@ -0,0 +1,135 @@
1
+ import EventEmitter from 'events';
2
+ import uWebSockets from 'uWebSockets.js';
3
+
4
+ import { getMessageBytes, Protocol, type Client, type ClientPrivate, ClientState, type ISendOptions, logger, debugMessage } from '@colyseus/core';
5
+
6
+ export class uWebSocketWrapper extends EventEmitter {
7
+ public ws: uWebSockets.WebSocket<any>;
8
+ constructor(ws: uWebSockets.WebSocket<any>) {
9
+ super();
10
+ this.ws = ws;
11
+ }
12
+ }
13
+
14
+ export const ReadyState = {
15
+ CONNECTING: 0,
16
+ OPEN: 1,
17
+ CLOSING: 2,
18
+ CLOSED: 3,
19
+ } as const;
20
+ export type ReadyState = (typeof ReadyState)[keyof typeof ReadyState];
21
+
22
+ export class uWebSocketClient implements Client, ClientPrivate {
23
+ '~messages': any;
24
+
25
+ public id: string;
26
+ public _ref: uWebSocketWrapper;
27
+
28
+ public sessionId: string;
29
+ public state: ClientState = ClientState.JOINING;
30
+ public readyState: number = ReadyState.OPEN;
31
+ public reconnectionToken: string;
32
+
33
+ public _enqueuedMessages: any[] = [];
34
+ public _afterNextPatchQueue;
35
+ public _reconnectionToken: string;
36
+ public _joinedAt: number;
37
+
38
+ constructor(id: string, _ref: uWebSocketWrapper) {
39
+ this.id = this.sessionId = id;
40
+ this._ref = _ref;
41
+ _ref.on('close', () => this.readyState = ReadyState.CLOSED);
42
+ }
43
+
44
+ get ref() { return this._ref; }
45
+ set ref(_ref: uWebSocketWrapper) {
46
+ this._ref = _ref;
47
+ this.readyState = ReadyState.OPEN;
48
+ }
49
+
50
+ public sendBytes(type: string | number, bytes: Buffer | Uint8Array, options?: ISendOptions) {
51
+ debugMessage("send bytes(to %s): '%s' -> %j", this.sessionId, type, bytes);
52
+
53
+ this.enqueueRaw(
54
+ getMessageBytes.raw(Protocol.ROOM_DATA_BYTES, type, undefined, bytes),
55
+ options,
56
+ );
57
+ }
58
+
59
+ public send(messageOrType: any, messageOrOptions?: any | ISendOptions, options?: ISendOptions) {
60
+ debugMessage("send(to %s): '%s' -> %O", this.sessionId, messageOrType, messageOrOptions);
61
+
62
+ this.enqueueRaw(
63
+ getMessageBytes.raw(Protocol.ROOM_DATA, messageOrType, messageOrOptions),
64
+ options,
65
+ );
66
+ }
67
+
68
+ public enqueueRaw(data: Uint8Array | Buffer, options?: ISendOptions) {
69
+ // use room's afterNextPatch queue
70
+ if (options?.afterNextPatch) {
71
+ this._afterNextPatchQueue.push([this, [data]]);
72
+ return;
73
+ }
74
+
75
+ if (this.state === ClientState.JOINING) {
76
+ // sending messages during `onJoin`.
77
+ // - the client-side cannot register "onMessage" callbacks at this point.
78
+ // - enqueue the messages to be send after JOIN_ROOM message has been sent
79
+ // - create a new buffer for enqueued messages, as the underlying buffer might be modified
80
+ this._enqueuedMessages.push(data);
81
+ return;
82
+ }
83
+
84
+ this.raw(data, options);
85
+ }
86
+
87
+ public raw(data: Uint8Array | Buffer, options?: ISendOptions, cb?: (err?: Error) => void) {
88
+ // skip if client not open
89
+ if (this.readyState !== ReadyState.OPEN) {
90
+ return;
91
+ }
92
+
93
+ this._ref.ws.send(data, true, false);
94
+ }
95
+
96
+ public error(code: number, message: string = '', cb?: (err?: Error) => void) {
97
+ this.raw(getMessageBytes[Protocol.ERROR](code, message));
98
+
99
+ if (cb) {
100
+ // delay callback execution - uWS doesn't acknowledge when the message was sent
101
+ // (same API as "ws" transport)
102
+ setTimeout(cb, 1);
103
+ }
104
+ }
105
+
106
+ public leave(code?: number, data?: string) {
107
+ if (this.readyState !== ReadyState.OPEN) {
108
+ // connection already closed. ignore.
109
+ return;
110
+ }
111
+
112
+ this.readyState = ReadyState.CLOSING;
113
+
114
+ if (code !== undefined) {
115
+ this._ref.ws.end(code, data);
116
+
117
+ } else {
118
+ this._ref.ws.close();
119
+ }
120
+ }
121
+
122
+ public close(code?: number, data?: string) {
123
+ logger.warn('DEPRECATION WARNING: use client.leave() instead of client.close()');
124
+ try {
125
+ throw new Error();
126
+ } catch (e: any) {
127
+ logger.info(e.stack);
128
+ }
129
+ this.leave(code, data);
130
+ }
131
+
132
+ public toJSON() {
133
+ return { sessionId: this.sessionId, readyState: this.readyState };
134
+ }
135
+ }
@@ -0,0 +1,354 @@
1
+ import type { IncomingHttpHeaders } from 'http';
2
+ import querystring, { type ParsedUrlQuery } from 'querystring';
3
+ import uWebSockets, { type WebSocket } from 'uWebSockets.js';
4
+ import expressify, { Application } from "uwebsockets-express";
5
+
6
+ import { type AuthContext, Transport, HttpServerMock, ErrorCode, matchMaker, Protocol, getBearerToken, debugAndPrintError, spliceOne, connectClientToRoom } from '@colyseus/core';
7
+ import { uWebSocketClient, uWebSocketWrapper } from './uWebSocketClient.ts';
8
+
9
+ export type TransportOptions = Omit<uWebSockets.WebSocketBehavior<any>, "upgrade" | "open" | "pong" | "close" | "message">;
10
+
11
+ type RawWebSocketClient = uWebSockets.WebSocket<any> & {
12
+ url: string,
13
+ searchParams: ParsedUrlQuery,
14
+ context: AuthContext,
15
+ };
16
+
17
+ export class uWebSocketsTransport extends Transport {
18
+ public app: uWebSockets.TemplatedApp;
19
+
20
+ protected clients: RawWebSocketClient[] = [];
21
+ protected clientWrappers = new WeakMap<RawWebSocketClient, uWebSocketWrapper>();
22
+
23
+ private _listeningSocket: any;
24
+ private _originalRawSend: typeof uWebSocketClient.prototype.raw | null = null;
25
+
26
+ constructor(options: TransportOptions = {}, appOptions: uWebSockets.AppOptions = {}) {
27
+ super();
28
+
29
+ this.app = (appOptions.cert_file_name && appOptions.key_file_name)
30
+ ? uWebSockets.SSLApp(appOptions)
31
+ : uWebSockets.App(appOptions);
32
+
33
+ if (options.maxBackpressure === undefined) {
34
+ options.maxBackpressure = 1024 * 1024;
35
+ }
36
+
37
+ if (options.compression === undefined) {
38
+ options.compression = uWebSockets.DISABLED;
39
+ }
40
+
41
+ if (options.maxPayloadLength === undefined) {
42
+ options.maxPayloadLength = 4 * 1024;
43
+ }
44
+
45
+ if (options.sendPingsAutomatically === undefined) {
46
+ options.sendPingsAutomatically = true;
47
+ }
48
+
49
+ // https://github.com/colyseus/colyseus/issues/458
50
+ // Adding a mock object for Transport.server
51
+ if(!this.server) {
52
+ // @ts-ignore
53
+ this.server = new HttpServerMock();
54
+ }
55
+
56
+ this.app.ws('/*', {
57
+ ...options,
58
+
59
+ upgrade: (res, req, context) => {
60
+ // get all headers
61
+ const headers: {[id: string]: string} = {};
62
+ req.forEach((key, value) => headers[key] = value);
63
+
64
+ const searchParams = querystring.parse(req.getQuery());
65
+
66
+ /* This immediately calls open handler, you must not use res after this call */
67
+ /* Spell these correctly */
68
+ res.upgrade(
69
+ {
70
+ url: req.getUrl(),
71
+ searchParams,
72
+ context: {
73
+ token: searchParams._authToken ?? getBearerToken(req.getHeader('authorization')),
74
+ headers,
75
+ ip: headers['x-real-ip'] ?? headers['x-forwarded-for'] ?? Buffer.from(res.getRemoteAddressAsText()).toString(),
76
+ }
77
+ },
78
+ req.getHeader('sec-websocket-key'),
79
+ req.getHeader('sec-websocket-protocol'),
80
+ req.getHeader('sec-websocket-extensions'),
81
+ context
82
+ );
83
+ },
84
+
85
+ open: async (ws: WebSocket<any>) => {
86
+ // ws.pingCount = 0;
87
+ await this.onConnection(ws as RawWebSocketClient);
88
+ },
89
+
90
+ // pong: (ws: RawWebSocketClient) => {
91
+ // ws.pingCount = 0;
92
+ // },
93
+
94
+ close: (ws: WebSocket<any>, code: number, message: ArrayBuffer) => {
95
+ // remove from client list
96
+ spliceOne(this.clients, this.clients.indexOf(ws as RawWebSocketClient));
97
+
98
+ const clientWrapper = this.clientWrappers.get(ws as RawWebSocketClient);
99
+ if (clientWrapper) {
100
+ this.clientWrappers.delete(ws as RawWebSocketClient);
101
+
102
+ // emit 'close' on wrapper
103
+ clientWrapper.emit('close', code);
104
+ }
105
+ },
106
+
107
+ message: (ws: WebSocket<any>, message: ArrayBuffer, isBinary: boolean) => {
108
+ // emit 'message' on wrapper
109
+ this.clientWrappers.get(ws as RawWebSocketClient)?.emit('message', Buffer.from(message));
110
+ },
111
+
112
+ });
113
+
114
+ this.registerMatchMakeRequest();
115
+ }
116
+
117
+ public listen(port: number, hostname?: string, backlog?: number, listeningListener?: () => void) {
118
+ const callback = (listeningSocket: any) => {
119
+ this._listeningSocket = listeningSocket;
120
+ listeningListener?.();
121
+ // @ts-ignore
122
+ this.server.emit("listening"); // Mocking Transport.server behaviour, https://github.com/colyseus/colyseus/issues/458
123
+ };
124
+
125
+ if (typeof(port) === "string") {
126
+ // @ts-ignore
127
+ this.app.listen_unix(callback, port);
128
+
129
+ } else {
130
+ this.app.listen(port, callback);
131
+
132
+ }
133
+ return this;
134
+ }
135
+
136
+ public shutdown() {
137
+ if (this._listeningSocket) {
138
+ uWebSockets.us_listen_socket_close(this._listeningSocket);
139
+ // @ts-ignore
140
+ this.server.emit("close"); // Mocking Transport.server behaviour, https://github.com/colyseus/colyseus/issues/458
141
+ }
142
+ }
143
+
144
+ public simulateLatency(milliseconds: number) {
145
+ if (this._originalRawSend == null) {
146
+ this._originalRawSend = uWebSocketClient.prototype.raw;
147
+ }
148
+
149
+ const originalRawSend = this._originalRawSend;
150
+ uWebSocketClient.prototype.raw = milliseconds <= Number.EPSILON ? originalRawSend : function (...args: any[]) {
151
+ // copy buffer
152
+ let [buf, ...rest] = args;
153
+ buf = Buffer.from(buf);
154
+ // @ts-ignore
155
+ setTimeout(() => originalRawSend.apply(this, [buf, ...rest]), milliseconds);
156
+ };
157
+ }
158
+
159
+ protected async onConnection(rawClient: RawWebSocketClient) {
160
+ const wrapper = new uWebSocketWrapper(rawClient);
161
+ // keep reference to client and its wrapper
162
+ this.clients.push(rawClient);
163
+ this.clientWrappers.set(rawClient, wrapper);
164
+
165
+ const url = rawClient.url;
166
+ const searchParams = rawClient.searchParams;
167
+
168
+ const sessionId = searchParams.sessionId as string;
169
+ const processAndRoomId = url.match(/\/[a-zA-Z0-9_\-]+\/([a-zA-Z0-9_\-]+)$/);
170
+ const roomId = processAndRoomId && processAndRoomId[1];
171
+
172
+ // If sessionId is not provided, allow ping-pong utility.
173
+ if (!sessionId && !roomId) {
174
+ // Disconnect automatically after 1 second if no message is received.
175
+ const timeout = setTimeout(() => rawClient.close(), 1000);
176
+ wrapper.on('message', (_) => rawClient.send(new Uint8Array([Protocol.PING]), true));
177
+ wrapper.on('close', () => clearTimeout(timeout));
178
+ return;
179
+ }
180
+
181
+ const room = matchMaker.getLocalRoomById(roomId);
182
+ const client = new uWebSocketClient(sessionId, wrapper);
183
+ const reconnectionToken = searchParams.reconnectionToken as string;
184
+ const skipHandshake = (searchParams.skipHandshake !== undefined);
185
+
186
+ try {
187
+ await connectClientToRoom(room, client, rawClient.context, {
188
+ reconnectionToken,
189
+ skipHandshake
190
+ });
191
+
192
+ } catch (e: any) {
193
+ debugAndPrintError(e);
194
+
195
+ // send error code to client then terminate
196
+ client.error(e.code, e.message, () => client.leave());
197
+ }
198
+ }
199
+
200
+ protected registerMatchMakeRequest() {
201
+
202
+ // TODO: DRY with Server.ts
203
+ const matchmakeRoute = 'matchmake';
204
+ const allowedRoomNameChars = /([a-zA-Z_\-0-9]+)/gi;
205
+
206
+ const writeHeaders = (res: uWebSockets.HttpResponse, requestHeaders: Headers) => {
207
+ // skip if aborted
208
+ if (res.aborted) { return; }
209
+
210
+ const headers = Object.assign(
211
+ {},
212
+ matchMaker.controller.DEFAULT_CORS_HEADERS,
213
+ matchMaker.controller.getCorsHeaders(requestHeaders)
214
+ );
215
+
216
+ for (const header in headers) {
217
+ res.writeHeader(header, headers[header].toString());
218
+ }
219
+
220
+ return true;
221
+ }
222
+
223
+ const writeError = (res: uWebSockets.HttpResponse, error: { code: number, error: string }) => {
224
+ // skip if aborted
225
+ if (res.aborted) { return; }
226
+
227
+ res.cork(() => {
228
+ res.writeStatus("406 Not Acceptable");
229
+ res.end(JSON.stringify(error));
230
+ });
231
+ }
232
+
233
+ const onAborted = (res: uWebSockets.HttpResponse) => {
234
+ res.aborted = true;
235
+ };
236
+
237
+ this.app.options("/matchmake/*", (res, req) => {
238
+ res.onAborted(() => onAborted(res));
239
+
240
+ // cache all headers
241
+ const reqHeaders = new Headers();
242
+ req.forEach((key, value) => reqHeaders.set(key, value));
243
+
244
+ if (writeHeaders(res, reqHeaders)) {
245
+ res.writeStatus("204 No Content");
246
+ res.end();
247
+ }
248
+ });
249
+
250
+
251
+ // @ts-ignore
252
+ this.app.post("/matchmake/*", (res, req) => {
253
+ res.onAborted(() => onAborted(res));
254
+
255
+ // do not accept matchmaking requests if already shutting down
256
+ if (matchMaker.state === matchMaker.MatchMakerState.SHUTTING_DOWN) {
257
+ return res.close();
258
+ }
259
+
260
+ // cache all headers
261
+ const headers = new Headers();
262
+ req.forEach((key, value) => headers.set(key, value));
263
+
264
+ writeHeaders(res, headers);
265
+ res.writeHeader('Content-Type', 'application/json');
266
+
267
+ const url = req.getUrl();
268
+ const matchedParams = url.match(allowedRoomNameChars);
269
+ const matchmakeIndex = matchedParams.indexOf(matchmakeRoute);
270
+
271
+ const token = getBearerToken(headers['authorization']);
272
+
273
+ // read json body
274
+ this.readJson(res, async (clientOptions) => {
275
+ try {
276
+ if (clientOptions === undefined) {
277
+ throw new Error("invalid JSON input");
278
+ }
279
+
280
+ const method = matchedParams[matchmakeIndex + 1];
281
+ const roomName = matchedParams[matchmakeIndex + 2] || '';
282
+
283
+ const response = await matchMaker.controller.invokeMethod(
284
+ method,
285
+ roomName,
286
+ clientOptions,
287
+ {
288
+ token,
289
+ headers,
290
+ ip: headers.get('x-real-ip') ?? headers.get('x-forwarded-for') ?? Buffer.from(res.getRemoteAddressAsText()).toString()
291
+ }
292
+ );
293
+
294
+ if (!res.aborted) {
295
+ res.cork(() => {
296
+ res.writeStatus("200 OK");
297
+ res.end(JSON.stringify(response));
298
+ });
299
+ }
300
+
301
+ } catch (e: any) {
302
+ debugAndPrintError(e);
303
+ writeError(res, {
304
+ code: e.code || ErrorCode.MATCHMAKE_UNHANDLED,
305
+ error: e.message
306
+ });
307
+ }
308
+
309
+ });
310
+ });
311
+ }
312
+
313
+ /* Helper function for reading a posted JSON body */
314
+ /* Extracted from https://github.com/uNetworking/uWebSockets.js/blob/master/examples/JsonPost.js */
315
+ private readJson(res: uWebSockets.HttpResponse, cb: (json: any) => void) {
316
+ let buffer: Buffer;
317
+ /* Register data cb */
318
+ res.onData((ab, isLast) => {
319
+ let chunk = Buffer.from(ab);
320
+ if (isLast) {
321
+ let json;
322
+ if (buffer) {
323
+ try {
324
+ // @ts-ignore
325
+ json = JSON.parse(Buffer.concat([buffer, chunk]));
326
+ } catch (e) {
327
+ /* res.close calls onAborted */
328
+ // res.close();
329
+ cb(undefined);
330
+ return;
331
+ }
332
+ cb(json);
333
+ } else {
334
+ try {
335
+ // @ts-ignore
336
+ json = JSON.parse(chunk);
337
+ } catch (e) {
338
+ /* res.close calls onAborted */
339
+ // res.close();
340
+ cb(undefined);
341
+ return;
342
+ }
343
+ cb(json);
344
+ }
345
+ } else {
346
+ if (buffer) {
347
+ buffer = Buffer.concat([buffer, chunk]);
348
+ } else {
349
+ buffer = Buffer.concat([chunk]);
350
+ }
351
+ }
352
+ });
353
+ }
354
+ }