@quakejs/master 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.
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ import MasterClient from './master-client.js';
2
+ import MasterServer from './master-server.js';
3
+
4
+ export default { MasterClient, MasterServer };
5
+ export { MasterClient, MasterServer };
package/master-base.js ADDED
@@ -0,0 +1,333 @@
1
+ function generateChallenge () {
2
+ const CHALLENGE_CHARS = [
3
+ '!', '#', '$', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', '<', '=', '>', '?', '@',
4
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
5
+ '[', ']', '^', '_', '`',
6
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
7
+ '{', '|', '}', '~'
8
+ ];
9
+ const CHALLENGE_MIN_LENGTH = 9;
10
+ const CHALLENGE_MAX_LENGTH = 12;
11
+
12
+ let challenge = '';
13
+ let length = CHALLENGE_MIN_LENGTH - 1;
14
+
15
+ length += parseInt(Math.random() * (CHALLENGE_MAX_LENGTH - CHALLENGE_MIN_LENGTH + 1), 10);
16
+
17
+ for (let i = 0; i < length; i++) {
18
+ challenge += CHALLENGE_CHARS[Math.floor(Math.random() * CHALLENGE_CHARS.length)];
19
+ }
20
+
21
+ return challenge;
22
+ }
23
+
24
+ function parseInfoString (str) {
25
+ const info = {};
26
+ let i = 0;
27
+
28
+ while (str.charAt(i) === '\\') {
29
+ const j = str.indexOf('\\', i + 1);
30
+ const k = str.indexOf('\\', j + 1);
31
+
32
+ if (j === -1) {
33
+ break;
34
+ }
35
+
36
+ const key = str.substring(i + 1, j);
37
+
38
+ if (k >= 0) {
39
+ info[key] = str.substring(j + 1, k);
40
+ } else {
41
+ info[key] = str.substring(j + 1);
42
+ }
43
+
44
+ i = j;
45
+ }
46
+
47
+ return info;
48
+ }
49
+
50
+ function formatInfoString (info) {
51
+ let str = '';
52
+
53
+ for (const [key, value] of Object.entries(info)) {
54
+ str += `\\${key}\\${value}`;
55
+ }
56
+
57
+ return str;
58
+ }
59
+
60
+ function parsePlayerString (str) {
61
+ let [score, ping, ...name] = str.split(' ');
62
+
63
+ name = name.join(' ');
64
+
65
+ return {
66
+ score: parseInt(score, 10),
67
+ ping: parseInt(ping, 10),
68
+ name: name.replace(/^"(.*)"$/, '$1')
69
+ };
70
+ }
71
+
72
+ function formatPlayerString (player) {
73
+ return `${player.score} ${player.ping} "${player.name}"`;
74
+ }
75
+
76
+ class Message {
77
+ get type () {
78
+ return this.constructor.id;
79
+ }
80
+ }
81
+
82
+ class Heartbeat extends Message {
83
+ static id = 'heartbeat';
84
+
85
+ constructor (from) {
86
+ super();
87
+
88
+ if (typeof from === 'string') {
89
+ this.game = from.substring(14).trim();
90
+ } else {
91
+ this.game = from?.game;
92
+ }
93
+ }
94
+
95
+ toString () {
96
+ return `${this.type}`;
97
+ }
98
+ }
99
+
100
+ class Subscribe extends Message {
101
+ static id = 'subscribe';
102
+
103
+ constructor (msg) {
104
+ super();
105
+ }
106
+
107
+ toString () {
108
+ return `${this.type}`;
109
+ }
110
+ }
111
+
112
+ class GetInfo extends Message {
113
+ static id = 'getinfo';
114
+
115
+ constructor (msg) {
116
+ super();
117
+
118
+ this.challenge = generateChallenge();
119
+ }
120
+
121
+ toString () {
122
+ return `${this.type} ${this.challenge}`;
123
+ }
124
+ }
125
+
126
+ class GetInfoResponse extends Message {
127
+ static id = 'infoResponse';
128
+
129
+ constructor (from) {
130
+ super();
131
+
132
+ if (typeof from === 'string') {
133
+ const infoString = from.substring(17);
134
+ this.info = parseInfoString(infoString);
135
+ } else {
136
+ this.info = from?.info;
137
+ }
138
+ }
139
+
140
+ toString () {
141
+ return `${this.type} ${formatInfoString(this.info)}`;
142
+ }
143
+ }
144
+
145
+ class GetStatus extends Message {
146
+ static id = 'getstatus';
147
+
148
+ constructor (msg) {
149
+ super();
150
+ }
151
+
152
+ toString () {
153
+ return `${this.type}`;
154
+ }
155
+ }
156
+
157
+ class GetStatusResponse extends Message {
158
+ static id = 'statusResponse';
159
+
160
+ constructor (from) {
161
+ super();
162
+
163
+ if (typeof from === 'string') {
164
+ const lines = from.split('\n').filter(x => x);
165
+
166
+ this.info = parseInfoString(lines[1]);
167
+ this.players = lines.slice(2).map(x => parsePlayerString(x));
168
+ } else {
169
+ this.info = from?.info;
170
+ this.players = from?.players;
171
+ }
172
+ }
173
+
174
+ toString () {
175
+ const lines = [
176
+ this.type,
177
+ formatInfoString(this.info),
178
+ ...this.players.map(x => formatPlayerString(x))
179
+ ];
180
+ return lines.join('\n');
181
+ }
182
+ }
183
+
184
+ class GetServers extends Message {
185
+ static id = 'getservers';
186
+
187
+ constructor (from) {
188
+ super();
189
+
190
+ if (typeof from === 'string') {
191
+ const fields = from.substring(15).split(' ').filter(x => x);
192
+
193
+ this.protocol = parseInt(fields[0], 10);
194
+ this.keywords = fields.slice(1);
195
+ } else {
196
+ this.protocol = from?.protocol;
197
+ this.keywords = from?.keywords;
198
+ }
199
+ }
200
+
201
+ toString () {
202
+ let str = `${this.type} ${this.protocol}`;
203
+
204
+ for (const keyword of this.keywords) {
205
+ str += ` ${keyword}`;
206
+ }
207
+
208
+ return str;
209
+ }
210
+ }
211
+
212
+ class GetServersResponse extends Message {
213
+ static id = 'getserversResponse';
214
+
215
+ constructor (from) {
216
+ super();
217
+
218
+ if (typeof from === 'string') {
219
+ this.servers = [];
220
+
221
+ for (let i = 23; i < from.length - 7; i += 7) {
222
+ const bytes = [
223
+ from.charCodeAt(i + 1),
224
+ from.charCodeAt(i + 2),
225
+ from.charCodeAt(i + 3),
226
+ from.charCodeAt(i + 4),
227
+ from.charCodeAt(i + 5),
228
+ from.charCodeAt(i + 6)
229
+ ];
230
+ const addr = `${bytes[0]}.${bytes[1]}.${bytes[2]}.${bytes[3]}`;
231
+ const port = (bytes[4] << 8) | bytes[5];
232
+
233
+ this.servers.push({ addr, port });
234
+ }
235
+ } else {
236
+ this.servers = from?.servers;
237
+ }
238
+ }
239
+
240
+ toString () {
241
+ let str = `${this.type} `;
242
+
243
+ for (const server of this.servers) {
244
+ const octets = server.addr.split('.').map(x => parseInt(x, 10));
245
+
246
+ str += '\\';
247
+ str += String.fromCharCode(octets[0] & 0xff);
248
+ str += String.fromCharCode(octets[1] & 0xff);
249
+ str += String.fromCharCode(octets[2] & 0xff);
250
+ str += String.fromCharCode(octets[3] & 0xff);
251
+ str += String.fromCharCode((server.port & 0xff00) >> 8);
252
+ str += String.fromCharCode((server.port & 0x00ff) >> 0);
253
+ }
254
+
255
+ str += '\\EOT';
256
+
257
+ return str;
258
+ }
259
+ }
260
+
261
+ class MasterBase {
262
+ static #messages = new Map([
263
+ [Heartbeat.id, Heartbeat],
264
+ [Subscribe.id, Subscribe],
265
+ [GetInfo.id, GetInfo],
266
+ [GetInfoResponse.id, GetInfoResponse],
267
+ [GetStatus.id, GetStatus],
268
+ [GetStatusResponse.id, GetStatusResponse],
269
+ [GetServers.id, GetServers],
270
+ [GetServersResponse.id, GetServersResponse]
271
+ ]);
272
+
273
+ encode (id, opts) {
274
+ const MessageCtor = MasterBase.#messages.get(id);
275
+
276
+ if (!MessageCtor) {
277
+ return null;
278
+ }
279
+
280
+ const msg = new MessageCtor(opts);
281
+ const str = `\xff\xff\xff\xff${msg.toString()}`;
282
+
283
+ return Uint8Array.from(str, x => x.charCodeAt(0));
284
+ }
285
+
286
+ decode (buffer) {
287
+ let str;
288
+ let i;
289
+
290
+ if (typeof buffer === 'string') {
291
+ str = buffer;
292
+ } else {
293
+ str = String.fromCharCode.apply(null, buffer);
294
+ }
295
+
296
+ if (!str.startsWith('\xff\xff\xff\xff')) {
297
+ return null;
298
+ }
299
+
300
+ for (i = 4; i < str.length; i++) {
301
+ const c = str.charCodeAt(i);
302
+
303
+ if ((c < 65 || c > 90) && (c < 97 || c > 122)) {
304
+ break;
305
+ }
306
+ }
307
+
308
+ const id = str.substring(4, i);
309
+ const MessageCtor = MasterBase.#messages.get(id);
310
+
311
+ if (!MessageCtor) {
312
+ return null;
313
+ }
314
+
315
+ return new MessageCtor(str);
316
+ }
317
+
318
+ pretty (msg) {
319
+ let out = '';
320
+
321
+ for (let i = 0; i < msg.length; i++) {
322
+ if (msg[i] >= 32 && msg[i] <= 126) {
323
+ out += String.fromCharCode(msg[i]);
324
+ } else {
325
+ out += `\\x${msg[i].toString(16).padStart(2, '0')}`;
326
+ }
327
+ }
328
+
329
+ return out;
330
+ }
331
+ }
332
+
333
+ export default MasterBase;
@@ -0,0 +1,155 @@
1
+ import MasterBase from './master-base.js';
2
+
3
+ class MasterClient extends MasterBase {
4
+ #onserver;
5
+ #master;
6
+
7
+ constructor (onserver) {
8
+ super();
9
+
10
+ this.#onserver = onserver;
11
+ this.#master = null;
12
+ }
13
+
14
+ subscribe (host, port) {
15
+ let resendTimeout;
16
+
17
+ if (this.#master) {
18
+ this.#master.close();
19
+ this.#master = null;
20
+ }
21
+
22
+ this.#master = new WebTransport(`${host}:${port}`);
23
+
24
+ const writer = this.#master.datagrams.writable.getWriter();
25
+ const reader = this.#master.datagrams.readable.getReader();
26
+
27
+ this.#master.ready.then(async () => {
28
+ while (true) {
29
+ const { done, value } = await reader.read();
30
+
31
+ clearTimeout(resendTimeout);
32
+
33
+ if (done) {
34
+ break;
35
+ }
36
+
37
+ console.log(`${host}:${port} ---> ${this.pretty(value)}`);
38
+
39
+ const msg = this.decode(value);
40
+
41
+ if (msg && msg.type === 'getserversResponse') {
42
+ for (const server of msg.servers) {
43
+ this.#onserver(server.addr, server.port);
44
+ }
45
+ }
46
+ }
47
+ }).catch((e) => {
48
+ console.error(`Stream error, connection to ${host}:${port} lost`, e);
49
+ });
50
+
51
+ this.#master.closed.catch((e) => {
52
+ console.error(`Stream error, connection to ${host}:${port} lost`, e);
53
+ this.#master = null;
54
+ });
55
+
56
+ /* send until a response is received */
57
+ const send = () => {
58
+ writer.write(this.encode('subscribe'));
59
+
60
+ resendTimeout = setTimeout(send, 1000);
61
+ };
62
+
63
+ send();
64
+ }
65
+
66
+ async getinfo (addr, port) {
67
+ const session = new WebTransport(`https://${addr}:${port}`);
68
+ const writer = session.datagrams.writable.getWriter();
69
+ const reader = session.datagrams.readable.getReader();
70
+ let resendTimeout;
71
+ let begin;
72
+
73
+ await session.ready;
74
+
75
+ /* send request multiple times until something is read back */
76
+ const send = () => {
77
+ begin = performance.now();
78
+
79
+ writer.write(this.encode('getinfo'));
80
+
81
+ resendTimeout = setTimeout(send, 3000);
82
+ };
83
+
84
+ send();
85
+
86
+ /* read response */
87
+ const { done, value } = await reader.read();
88
+
89
+ clearTimeout(resendTimeout);
90
+
91
+ if (done) {
92
+ throw new Error('getinfo failed, no response received');
93
+ }
94
+
95
+ console.log(`${addr}:${port} ---> ${this.pretty(value)}`);
96
+
97
+ const msg = this.decode(value);
98
+
99
+ if (!msg || msg.type !== 'infoResponse') {
100
+ throw new Error('getinfo failed, didn\'t receive infoResponse');
101
+ }
102
+
103
+ msg.info.ping = Math.min((performance.now() - begin) >>> 0, 999);
104
+
105
+ session.close();
106
+
107
+ return msg.info;
108
+ }
109
+
110
+ async getstatus (addr, port) {
111
+ const session = new WebTransport(`https://${addr}:${port}`);
112
+ const writer = session.datagrams.writable.getWriter();
113
+ const reader = session.datagrams.readable.getReader();
114
+ let resendTimeout;
115
+ let begin;
116
+
117
+ await session.ready;
118
+
119
+ /* send request multiple times until something is read back */
120
+ const send = () => {
121
+ begin = performance.now();
122
+
123
+ writer.write(this.encode('getstatus'));
124
+
125
+ resendTimeout = setTimeout(send, 3000);
126
+ };
127
+
128
+ send();
129
+
130
+ /* read response */
131
+ const { done, value } = await reader.read();
132
+
133
+ clearTimeout(resendTimeout);
134
+
135
+ if (done) {
136
+ throw new Error('getstatus failed, no response received');
137
+ }
138
+
139
+ console.log(`${addr}:${port} ---> ${this.pretty(value)}`);
140
+
141
+ const msg = this.decode(value);
142
+
143
+ if (!msg || msg.type !== 'statusResponse') {
144
+ throw new Error('getstatus failed, didn\'t receive statusResponse');
145
+ }
146
+
147
+ msg.info.ping = Math.min((performance.now() - begin) >>> 0, 999);
148
+
149
+ session.close();
150
+
151
+ return { info: msg.info, players: msg.players };
152
+ }
153
+ }
154
+
155
+ export default MasterClient;
@@ -0,0 +1,228 @@
1
+ import { Http3Server } from '@fails-components/webtransport';
2
+
3
+ import MasterBase from './master-base.js';
4
+
5
+ class MasterServer extends MasterBase {
6
+ static #SERVER_PRUNE_INTERVAL = 350 * 1000;
7
+
8
+ #pruneInterval;
9
+ #listening;
10
+ #clients;
11
+ #servers;
12
+ #h3;
13
+
14
+ constructor (opts) {
15
+ super();
16
+
17
+ this.#pruneInterval = 0;
18
+ this.#listening = false;
19
+ this.#clients = [];
20
+ this.#servers = {};
21
+ this.#h3 = new Http3Server(opts);
22
+ }
23
+
24
+ #removeClient (session) {
25
+ const idx = this.#clients.indexOf(session);
26
+
27
+ if (idx === -1) {
28
+ return false;
29
+ }
30
+
31
+ this.#clients.splice(idx, 1);
32
+
33
+ return true;
34
+ }
35
+
36
+ #addClient (session) {
37
+ const idx = this.#clients.indexOf(session);
38
+
39
+ if (idx !== -1) {
40
+ return false;
41
+ }
42
+
43
+ this.#clients.push(session);
44
+
45
+ return true;
46
+ }
47
+
48
+ #updateServer (addr, port, info) {
49
+ const id = `${addr}:${port}`;
50
+ let server = this.#servers[id];
51
+
52
+ if (!server) {
53
+ server = this.#servers[id] = { addr, port };
54
+ }
55
+
56
+ /* FIXME does anything use info... */
57
+ server.lastUpdate = Date.now();
58
+ server.info = info;
59
+
60
+ /* send partial update to all clients */
61
+ for (const client of this.#clients) {
62
+ this.#sendServers(client, [server]);
63
+ }
64
+ }
65
+
66
+ #removeServer (id) {
67
+ delete this.#servers[id];
68
+
69
+ console.log(`${id} timed out, ${Object.keys(this.#servers).length} server(s) currently registered`);
70
+ }
71
+
72
+ #pruneServers () {
73
+ const now = Date.now();
74
+
75
+ for (const [id, server] of Object.entries(this.#servers)) {
76
+ const delta = now - server.lastUpdate;
77
+
78
+ if (delta > MasterServer.#SERVER_PRUNE_INTERVAL) {
79
+ this.#removeServer(id);
80
+ }
81
+ }
82
+ }
83
+
84
+ #sendServers (session, servers) {
85
+ const msg = this.encode('getserversResponse', { servers });
86
+
87
+ console.log(`${session.addr}:${session.port} <--- ${this.pretty(msg)}`);
88
+
89
+ session.write(msg);
90
+ }
91
+
92
+ #handleInfoResponse (session, info) {
93
+ if (info.challenge !== session.lastChallenge) {
94
+ console.error(`Invalid challenge ${info.challenge}, expected ${session.lastChallenge}`);
95
+ return;
96
+ }
97
+
98
+ this.#updateServer(session.addr, session.port, info);
99
+ }
100
+
101
+ #handleGetServers (session, req) {
102
+ /* FIXME honor req.protocol and req.keywords */
103
+ this.#sendServers(session, Object.values(this.#servers));
104
+ }
105
+
106
+ #handleSubscribe (session) {
107
+ if (!this.#addClient(session)) {
108
+ return;
109
+ }
110
+
111
+ /* send all servers upon subscribing */
112
+ this.#sendServers(session, Object.values(this.#servers));
113
+ }
114
+
115
+ #handleHeartbeat (session, game) {
116
+ if (game !== 'QuakeArena-1') {
117
+ console.log(`Invalid game ${game}, expected QuakeArena-1`);
118
+ return;
119
+ }
120
+
121
+ const msg = this.encode('getinfo');
122
+
123
+ console.log(`${session.addr}:${session.port} <--- ${this.pretty(msg)}`);
124
+
125
+ session.lastChallenge = msg.challenge;
126
+
127
+ session.write(msg);
128
+ }
129
+
130
+ #handleEmscriptenPort (session, data) {
131
+ session.port = parseInt(data, 10);
132
+ }
133
+
134
+ listen () {
135
+ if (this.#listening) {
136
+ return;
137
+ }
138
+
139
+ this.#h3.ready.then(async () => {
140
+ const sessionStream = await this.#h3.sessionStream('/');
141
+ const sessionReader = sessionStream.getReader();
142
+
143
+ console.log(`listening on ${this.#h3.host}:${this.#h3.port}`);
144
+
145
+ while (true) {
146
+ const { done, value } = await sessionReader.read();
147
+ const session = value;
148
+
149
+ if (done) {
150
+ break;
151
+ }
152
+
153
+ session.ready.then(async () => {
154
+ const writer = session.datagrams.createWritable().getWriter();
155
+ const reader = session.datagrams.readable.getReader();
156
+ const [addr, port] = session.peerAddress.split(':');
157
+ let first = true;
158
+
159
+ session.addr = addr;
160
+ session.port = port;
161
+
162
+ session.write = (data) => {
163
+ writer.write(data);
164
+ };
165
+
166
+ while (true) {
167
+ const { done, value } = await reader.read();
168
+
169
+ if (done) {
170
+ return;
171
+ }
172
+
173
+ console.log(`${session.addr}:${session.port} ---> ${this.pretty(value)}`);
174
+
175
+ if (first) {
176
+ const str = String.fromCharCode.apply(null, value);
177
+
178
+ first = false;
179
+
180
+ if (str.startsWith('\xff\xff\xff\xffport')) {
181
+ this.#handleEmscriptenPort(session, str.substr(9));
182
+ continue;
183
+ }
184
+ }
185
+
186
+ const msg = this.decode(value);
187
+
188
+ switch (msg?.type) {
189
+ case 'heartbeat':
190
+ this.#handleHeartbeat(session, msg);
191
+ break;
192
+ case 'subscribe':
193
+ this.#handleSubscribe(session, msg);
194
+ break;
195
+ case 'getservers':
196
+ this.#handleGetServers(session, msg);
197
+ break;
198
+ case 'getinfoResponse':
199
+ this.#handleInfoResponse(session, msg);
200
+ break;
201
+ default:
202
+ console.error('invalid message');
203
+ break;
204
+ }
205
+ }
206
+ }).catch((e) => {
207
+ console.error(`Stream error, peer ${session.addr}:${session.port}`, e);
208
+ });
209
+
210
+ session.closed.catch((e) => {
211
+ console.error(`Stream error, peer ${session.addr}:${session.port}`, e);
212
+ }).finally(() => {
213
+ this.#removeClient(session);
214
+ });
215
+ }
216
+ }).catch((e) => {
217
+ console.error(e);
218
+ });
219
+
220
+ this.#h3.startServer();
221
+
222
+ this.#pruneInterval = setInterval(this.#pruneServers.bind(this), MasterServer.#SERVER_PRUNE_INTERVAL);
223
+
224
+ this.#listening = true;
225
+ }
226
+ }
227
+
228
+ export default MasterServer;
package/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@quakejs/master",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "bin": "server.js",
7
+ "dependencies": {
8
+ "@fails-components/webtransport": "^1.5.1",
9
+ "@fails-components/webtransport-transport-http3-quiche": "^1.5.1"
10
+ }
11
+ }
package/server.js ADDED
@@ -0,0 +1,54 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import util from 'node:util';
4
+
5
+ import MasterServer from './master-server.js';
6
+
7
+ let args;
8
+
9
+ try {
10
+ args = util.parseArgs({
11
+ options: {
12
+ help: { type: 'boolean' },
13
+ cert: { type: 'string' },
14
+ key: { type: 'string' },
15
+ port: { type: 'string', default: '27950' }
16
+ }
17
+ });
18
+
19
+ if (args.values.help) {
20
+ throw new Error();
21
+ }
22
+
23
+ if (!args.values.cert) {
24
+ throw new Error('Missing required argument --cert');
25
+ }
26
+
27
+ if (!args.values.key) {
28
+ throw new Error('Missing required argument --key');
29
+ }
30
+ } catch (e) {
31
+ console.error('Usage: master.js --cert <cert.pem> --key <key.pem> [--port <num>]');
32
+
33
+ if (e.message) {
34
+ console.error();
35
+ console.error(e.message);
36
+ }
37
+
38
+ process.exit(1);
39
+ }
40
+
41
+ const port = parseInt(args.values.port);
42
+ const cert = await fs.readFile(args.values.cert);
43
+ const key = await fs.readFile(args.values.key);
44
+ const secret = crypto.randomBytes(16).toString('hex');
45
+
46
+ const server = new MasterServer({
47
+ host: '0.0.0.0',
48
+ port,
49
+ secret,
50
+ cert,
51
+ privKey: key
52
+ });
53
+
54
+ server.listen();