joinstick 0.0.0-stage → 0.2.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/AGENTS.md +374 -0
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/PROTOCOL.md +238 -0
- package/README.md +282 -2
- package/THIRD_PARTY_NOTICES.md +66 -0
- package/llms.txt +102 -0
- package/package.json +79 -4
- package/public/client.d.ts +88 -0
- package/public/client.js +182 -0
- package/public/host.d.ts +116 -0
- package/public/host.js +200 -0
- package/public/link.js +73 -0
- package/public/pad-layout.js +152 -0
- package/public/pad.css +270 -0
- package/public/pad.html +57 -0
- package/public/pad.js +345 -0
- package/src/cli.js +75 -0
- package/src/lan.js +34 -0
- package/src/server.d.ts +23 -0
- package/src/server.js +491 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { WebSocketServer } from 'ws';
|
|
6
|
+
import { renderSVG } from 'uqr';
|
|
7
|
+
import { isLoopback, lanAddresses } from './lan.js';
|
|
8
|
+
|
|
9
|
+
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
10
|
+
const PUBLIC = path.join(ROOT, 'public');
|
|
11
|
+
const SDK_FILES = new Set(['host.js', 'host.d.ts', 'client.js', 'client.d.ts', 'link.js', 'pad.js', 'pad-layout.js', 'pad.css']);
|
|
12
|
+
|
|
13
|
+
const LETTERS = 'BCDFGHJKLMNPQRSTVWXZ';
|
|
14
|
+
const CODE = /^[BCDFGHJKLMNPQRSTVWXZ]{4}$/;
|
|
15
|
+
const ID = /^[A-Za-z0-9_-]{1,16}$/;
|
|
16
|
+
const COLOR = /^#[0-9a-f]{3,8}$/i;
|
|
17
|
+
const TOKEN = /^[A-Za-z0-9_-]{16,64}$/;
|
|
18
|
+
const HOST_TOKEN = /^[0-9a-f]{32}$/;
|
|
19
|
+
|
|
20
|
+
const MAX_SLOTS = 8;
|
|
21
|
+
const MAX_BUTTONS = 8;
|
|
22
|
+
const MAX_ROOMS = 1000;
|
|
23
|
+
const MAX_ROOMS_PER_IP = 20;
|
|
24
|
+
const MAX_FAILED_JOINS = 5; // per socket
|
|
25
|
+
const MAX_FAILED_JOINS_PER_IP = 20; // per minute, across sockets: slows down code guessing
|
|
26
|
+
const FAILED_WINDOW = 60_000;
|
|
27
|
+
const PING_EVERY = 2000;
|
|
28
|
+
const SILENCE = 5000;
|
|
29
|
+
const DEFAULT_LAYOUT = { stick: 'dpad', buttons: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] };
|
|
30
|
+
|
|
31
|
+
const TYPES = {
|
|
32
|
+
'.html': 'text/html; charset=utf-8',
|
|
33
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
34
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
35
|
+
'.css': 'text/css; charset=utf-8',
|
|
36
|
+
'.json': 'application/json',
|
|
37
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
38
|
+
'.md': 'text/plain; charset=utf-8',
|
|
39
|
+
'.ts': 'text/plain; charset=utf-8',
|
|
40
|
+
'.svg': 'image/svg+xml',
|
|
41
|
+
'.png': 'image/png',
|
|
42
|
+
'.jpg': 'image/jpeg',
|
|
43
|
+
'.jpeg': 'image/jpeg',
|
|
44
|
+
'.gif': 'image/gif',
|
|
45
|
+
'.webp': 'image/webp',
|
|
46
|
+
'.ico': 'image/x-icon',
|
|
47
|
+
'.wasm': 'application/wasm',
|
|
48
|
+
'.mp3': 'audio/mpeg',
|
|
49
|
+
'.ogg': 'audio/ogg',
|
|
50
|
+
'.wav': 'audio/wav',
|
|
51
|
+
'.woff2': 'font/woff2',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
class BadMessage extends Error {}
|
|
55
|
+
|
|
56
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
57
|
+
|
|
58
|
+
function check(ok, message) {
|
|
59
|
+
if (!ok) throw new BadMessage(message);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cleanText(v, max) {
|
|
63
|
+
return typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, max) : '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseLayout(l) {
|
|
67
|
+
if (l === undefined || l === null) return DEFAULT_LAYOUT;
|
|
68
|
+
check(isObj(l), 'layout must be an object');
|
|
69
|
+
const stick = l.stick ?? 'dpad';
|
|
70
|
+
check(stick === 'dpad' || stick === 'none', "layout.stick must be 'dpad' or 'none'");
|
|
71
|
+
const extra = {};
|
|
72
|
+
if (l.orientation !== undefined) {
|
|
73
|
+
check(['landscape', 'portrait', 'any'].includes(l.orientation), "layout.orientation must be 'landscape', 'portrait' or 'any'");
|
|
74
|
+
extra.orientation = l.orientation;
|
|
75
|
+
}
|
|
76
|
+
if (l.arrangement !== undefined) {
|
|
77
|
+
check(['grid', 'diamond', 'arc'].includes(l.arrangement), "layout.arrangement must be 'grid', 'diamond' or 'arc'");
|
|
78
|
+
extra.arrangement = l.arrangement;
|
|
79
|
+
}
|
|
80
|
+
if (l.haptics !== undefined) {
|
|
81
|
+
check(typeof l.haptics === 'boolean', 'layout.haptics must be a boolean');
|
|
82
|
+
extra.haptics = l.haptics;
|
|
83
|
+
}
|
|
84
|
+
const list = l.buttons ?? [];
|
|
85
|
+
check(Array.isArray(list) && list.length <= MAX_BUTTONS, `layout.buttons must be an array of at most ${MAX_BUTTONS}`);
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
const buttons = list.map((b) => {
|
|
88
|
+
check(isObj(b) && typeof b.id === 'string' && ID.test(b.id), 'button id must match [A-Za-z0-9_-]{1,16}');
|
|
89
|
+
check(!(b.id in Object.prototype), `button id '${b.id}' is reserved`);
|
|
90
|
+
check(!seen.has(b.id), `duplicate button id '${b.id}'`);
|
|
91
|
+
seen.add(b.id);
|
|
92
|
+
const label = b.label ?? b.id;
|
|
93
|
+
check(typeof label === 'string' && label.length <= 8, `button '${b.id}' label must be a string of at most 8 chars`);
|
|
94
|
+
check(b.color === undefined || (typeof b.color === 'string' && COLOR.test(b.color)), `button '${b.id}' color must be a hex color`);
|
|
95
|
+
check(b.size === undefined || b.size === 'large' || b.size === 'wide', `button '${b.id}' size must be 'large', 'wide' or omitted`);
|
|
96
|
+
check(b.haptic === undefined || (Number.isInteger(b.haptic) && b.haptic >= 0 && b.haptic <= 100), `button '${b.id}' haptic must be 0-100 ms`);
|
|
97
|
+
check(b.system === undefined || typeof b.system === 'boolean', `button '${b.id}' system must be a boolean`);
|
|
98
|
+
const out = { id: b.id, label };
|
|
99
|
+
if (b.color) out.color = b.color;
|
|
100
|
+
if (b.size) out.size = b.size;
|
|
101
|
+
if (b.haptic !== undefined) out.haptic = b.haptic;
|
|
102
|
+
if (b.system) out.system = true;
|
|
103
|
+
return out;
|
|
104
|
+
});
|
|
105
|
+
return { stick, ...extra, buttons };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseIds(v, name) {
|
|
109
|
+
check(Array.isArray(v) && v.length <= MAX_BUTTONS && v.every((id) => typeof id === 'string' && ID.test(id)), `${name} must be an array of button ids`);
|
|
110
|
+
return [...v];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parsePatch(p) {
|
|
114
|
+
check(isObj(p), 'patch must be an object');
|
|
115
|
+
const out = {};
|
|
116
|
+
if (p.title !== undefined) {
|
|
117
|
+
check(typeof p.title === 'string', 'title must be a string');
|
|
118
|
+
out.title = cleanText(p.title, 20);
|
|
119
|
+
}
|
|
120
|
+
if (p.color !== undefined) {
|
|
121
|
+
check(typeof p.color === 'string' && COLOR.test(p.color), 'color must be a hex color');
|
|
122
|
+
out.color = p.color;
|
|
123
|
+
}
|
|
124
|
+
if (p.highlight !== undefined) out.highlight = parseIds(p.highlight, 'highlight');
|
|
125
|
+
if (p.disabled !== undefined) out.disabled = parseIds(p.disabled, 'disabled');
|
|
126
|
+
if (p.vibrate !== undefined) {
|
|
127
|
+
check(Number.isInteger(p.vibrate) && p.vibrate >= 0 && p.vibrate <= 2000, 'vibrate must be 0-2000 ms');
|
|
128
|
+
out.vibrate = p.vibrate;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseButtons(b) {
|
|
134
|
+
check(isObj(b), 'b must be an object');
|
|
135
|
+
const keys = Object.keys(b);
|
|
136
|
+
check(keys.length <= 16 && keys.every((k) => ID.test(k) && !(k in Object.prototype) && typeof b[k] === 'boolean'), 'b must map button ids to booleans');
|
|
137
|
+
return b;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function axis(v) {
|
|
141
|
+
check(typeof v === 'number' && Number.isFinite(v), 'x and y must be numbers');
|
|
142
|
+
return Math.max(-1, Math.min(1, v));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function send(ws, msg) {
|
|
146
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify(msg));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function fatal(ws, code) {
|
|
150
|
+
send(ws, { t: 'error', code });
|
|
151
|
+
ws.close(4000, code);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Add Joinstick to a Node http(s) server: the WebSocket endpoint, the browser
|
|
156
|
+
* SDKs, the pad page, QR codes and (optionally) a static directory.
|
|
157
|
+
* Existing 'request' listeners keep working for every other path.
|
|
158
|
+
*
|
|
159
|
+
* @param {import('node:http').Server} server
|
|
160
|
+
* @param {{ static?: string, publicUrl?: string, hostGraceMs?: number, clientIpHeader?: string }} [opts]
|
|
161
|
+
* @returns {{ close(): void }}
|
|
162
|
+
*/
|
|
163
|
+
export function attach(server, opts = {}) {
|
|
164
|
+
const publicUrl = opts.publicUrl?.replace(/\/+$/, '');
|
|
165
|
+
const staticDir = opts.static && path.resolve(opts.static);
|
|
166
|
+
const hostGrace = opts.hostGraceMs ?? 60_000;
|
|
167
|
+
const ipHeader = opts.clientIpHeader?.toLowerCase();
|
|
168
|
+
const rooms = new Map();
|
|
169
|
+
const failedJoins = new Map(); // ip -> { count, since }
|
|
170
|
+
// Right after a (re)start, phones of recreated rooms retry their joins
|
|
171
|
+
// until the game page is back; those misses are not guessing.
|
|
172
|
+
const countFailuresFrom = Date.now() + hostGrace;
|
|
173
|
+
let closed = false;
|
|
174
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: 8 * 1024 });
|
|
175
|
+
|
|
176
|
+
// The visitor's address for the per-IP limits. Behind a proxy, the header it sets;
|
|
177
|
+
// only when configured, because anyone can send that header straight to us.
|
|
178
|
+
const clientIp = (req) => (ipHeader && String(req.headers[ipHeader] ?? '').split(',')[0].trim()) || req.socket.remoteAddress;
|
|
179
|
+
|
|
180
|
+
function joinBase(req) {
|
|
181
|
+
if (publicUrl) return publicUrl;
|
|
182
|
+
const proto = req.socket.encrypted ? 'https' : 'http';
|
|
183
|
+
const host = req.headers.host ?? '';
|
|
184
|
+
const hostname = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
|
|
185
|
+
if (host && !isLoopback(hostname)) return `${proto}://${host}`;
|
|
186
|
+
const lan = lanAddresses()[0];
|
|
187
|
+
const port = server.address()?.port;
|
|
188
|
+
return lan ? `${proto}://${lan.address}:${port}` : `${proto}://${host || `localhost:${port}`}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function newCode() {
|
|
192
|
+
let code;
|
|
193
|
+
do code = Array.from({ length: 4 }, () => LETTERS[crypto.randomInt(LETTERS.length)]).join('');
|
|
194
|
+
while (rooms.has(code));
|
|
195
|
+
return code;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function slotList(room) {
|
|
199
|
+
return room.slots.map((s, i) => ({ slot: i + 1, connected: !!s.ws, name: s.name }));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function freeSlots(room) {
|
|
203
|
+
return room.slots.flatMap((s, i) => (s.ws ? [] : [i + 1]));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function slotOf(room, n) {
|
|
207
|
+
check(Number.isInteger(n) && n >= 1 && n <= room.slots.length, `slot must be 1-${room.slots.length}`);
|
|
208
|
+
return room.slots[n - 1];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function closeRoom(room) {
|
|
212
|
+
rooms.delete(room.code);
|
|
213
|
+
for (const s of room.slots) if (s.ws) fatal(s.ws, 'room-closed');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function onCreate(ws, m, req) {
|
|
217
|
+
if (m.v !== 1) return fatal(ws, 'version');
|
|
218
|
+
const n = m.slots ?? 2;
|
|
219
|
+
check(Number.isInteger(n) && n >= 1 && n <= MAX_SLOTS, `slots must be 1-${MAX_SLOTS}`);
|
|
220
|
+
const layout = parseLayout(m.layout);
|
|
221
|
+
const wanted = typeof m.room === 'string' ? m.room.toUpperCase() : '';
|
|
222
|
+
let room = rooms.get(wanted);
|
|
223
|
+
|
|
224
|
+
const ip = ws.ip;
|
|
225
|
+
let resumed = false;
|
|
226
|
+
if (room && room.hostToken === m.hostToken && room.slots.length === n) {
|
|
227
|
+
// Resume: same room, same pads. Pads get a fresh 'joined' when the
|
|
228
|
+
// layout changed or the host asks to reset pad state (game reloaded).
|
|
229
|
+
resumed = true;
|
|
230
|
+
clearTimeout(room.timer);
|
|
231
|
+
const old = room.host;
|
|
232
|
+
room.host = ws;
|
|
233
|
+
if (old) fatal(old, 'replaced');
|
|
234
|
+
const relayout = JSON.stringify(layout) !== JSON.stringify(room.layout);
|
|
235
|
+
room.layout = layout;
|
|
236
|
+
if (m.resetPads === true) for (const s of room.slots) s.pad = {};
|
|
237
|
+
if (relayout || m.resetPads === true) {
|
|
238
|
+
room.slots.forEach((s, i) => send(s.ws, { t: 'joined', room: room.code, slot: i + 1, layout, pad: s.pad }));
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
if (rooms.size >= MAX_ROOMS) return fatal(ws, 'server-full');
|
|
242
|
+
// ponytail: per-IP cap only; behind a tunnel without clientIpHeader every host shares one IP.
|
|
243
|
+
if ([...rooms.values()].filter((r) => r.ip === ip).length >= MAX_ROOMS_PER_IP) return fatal(ws, 'server-full');
|
|
244
|
+
// Recreate (e.g. after a server restart) keeps the code so pads can rejoin.
|
|
245
|
+
const recreate = !room && CODE.test(wanted) && HOST_TOKEN.test(m.hostToken ?? '');
|
|
246
|
+
room = {
|
|
247
|
+
code: recreate ? wanted : newCode(),
|
|
248
|
+
hostToken: recreate ? m.hostToken : crypto.randomBytes(16).toString('hex'),
|
|
249
|
+
layout,
|
|
250
|
+
slots: Array.from({ length: n }, () => ({ ws: null, token: null, name: '', pad: {}, input: null })),
|
|
251
|
+
host: ws,
|
|
252
|
+
timer: null,
|
|
253
|
+
ip,
|
|
254
|
+
};
|
|
255
|
+
rooms.set(room.code, room);
|
|
256
|
+
}
|
|
257
|
+
ws.role = 'host';
|
|
258
|
+
ws.room = room;
|
|
259
|
+
room.joinBase = joinBase(req);
|
|
260
|
+
send(ws, { t: 'created', room: room.code, hostToken: room.hostToken, joinBase: room.joinBase, layout: room.layout, slots: slotList(room) });
|
|
261
|
+
// A refreshed game must still see a direction or button held through the refresh.
|
|
262
|
+
if (resumed) room.slots.forEach((s, i) => s.ws && s.input && send(ws, { t: 'input', slot: i + 1, ...s.input }));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function onJoin(ws, m) {
|
|
266
|
+
if (m.v !== 1) return fatal(ws, 'version');
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
let recent = failedJoins.get(ws.ip);
|
|
269
|
+
if (!recent || now - recent.since >= FAILED_WINDOW) recent = { count: 0, since: now };
|
|
270
|
+
// Keyed by client address: behind a proxy without clientIpHeader, one guesser
|
|
271
|
+
// locks every remote phone out for a minute.
|
|
272
|
+
if (recent.count >= MAX_FAILED_JOINS_PER_IP) return fatal(ws, 'rate-limited');
|
|
273
|
+
const fail = (code, extra) => {
|
|
274
|
+
send(ws, { t: 'error', code, ...extra });
|
|
275
|
+
if (now >= countFailuresFrom) failedJoins.set(ws.ip, { ...recent, count: recent.count + 1 });
|
|
276
|
+
if (++ws.failedJoins >= MAX_FAILED_JOINS) ws.close(4000, 'too many failed joins');
|
|
277
|
+
};
|
|
278
|
+
if (typeof m.token !== 'string' || !TOKEN.test(m.token)) return fail('bad-message', { message: 'token must match [A-Za-z0-9_-]{16,64}' });
|
|
279
|
+
const room = rooms.get(typeof m.room === 'string' ? m.room.toUpperCase() : '');
|
|
280
|
+
if (!room) return fail('room-not-found');
|
|
281
|
+
|
|
282
|
+
let i;
|
|
283
|
+
if (m.slot === undefined || m.slot === null) {
|
|
284
|
+
i = room.slots.findIndex((s) => s.token === m.token);
|
|
285
|
+
if (i < 0) i = room.slots.findIndex((s) => !s.ws);
|
|
286
|
+
if (i < 0) return fail('slot-taken', { free: [] });
|
|
287
|
+
} else {
|
|
288
|
+
if (!Number.isInteger(m.slot) || m.slot < 1 || m.slot > room.slots.length) return fail('bad-slot');
|
|
289
|
+
i = m.slot - 1;
|
|
290
|
+
}
|
|
291
|
+
const s = room.slots[i];
|
|
292
|
+
if (s.ws && s.token !== m.token) return fail('slot-taken', { free: freeSlots(room) });
|
|
293
|
+
|
|
294
|
+
const old = s.ws;
|
|
295
|
+
const rejoin = s.token === m.token;
|
|
296
|
+
Object.assign(s, { ws, token: m.token, name: cleanText(m.name, 20), input: null });
|
|
297
|
+
Object.assign(ws, { role: 'pad', room, slot: i + 1 });
|
|
298
|
+
if (old) fatal(old, 'replaced');
|
|
299
|
+
send(ws, { t: 'joined', room: room.code, slot: i + 1, layout: room.layout, pad: s.pad });
|
|
300
|
+
send(room.host, { t: 'join', slot: i + 1, name: s.name, rejoin });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function onHost(ws, m) {
|
|
304
|
+
const room = ws.room;
|
|
305
|
+
if (m.t === 'send') {
|
|
306
|
+
const msg = { t: 'message', data: m.data };
|
|
307
|
+
if (m.slot === null || m.slot === undefined) for (const s of room.slots) send(s.ws, msg);
|
|
308
|
+
else send(slotOf(room, m.slot).ws, msg);
|
|
309
|
+
} else if (m.t === 'pad') {
|
|
310
|
+
const s = slotOf(room, m.slot);
|
|
311
|
+
const patch = parsePatch(m.patch);
|
|
312
|
+
const { vibrate, ...keep } = patch;
|
|
313
|
+
Object.assign(s.pad, keep);
|
|
314
|
+
send(s.ws, { t: 'pad', patch });
|
|
315
|
+
} else if (m.t === 'kick') {
|
|
316
|
+
const s = slotOf(room, m.slot);
|
|
317
|
+
if (s.ws) {
|
|
318
|
+
s.ws.leaveReason = 'kicked';
|
|
319
|
+
s.token = null;
|
|
320
|
+
fatal(s.ws, 'kicked');
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
throw new BadMessage(`unknown host message '${m.t}'`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function onPad(ws, m) {
|
|
328
|
+
if (m.t === 'input') {
|
|
329
|
+
// Kept while the host is away so a resumed host gets it right away.
|
|
330
|
+
const input = { x: axis(m.x), y: axis(m.y), b: parseButtons(m.b ?? {}) };
|
|
331
|
+
ws.room.slots[ws.slot - 1].input = input;
|
|
332
|
+
send(ws.room.host, { t: 'input', slot: ws.slot, ...input });
|
|
333
|
+
} else if (m.t === 'msg') {
|
|
334
|
+
send(ws.room.host, { t: 'message', slot: ws.slot, data: m.data });
|
|
335
|
+
} else {
|
|
336
|
+
throw new BadMessage(`unknown pad message '${m.t}'`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function onClose(ws, code) {
|
|
341
|
+
const room = ws.room;
|
|
342
|
+
if (!room || closed) return;
|
|
343
|
+
if (ws.role === 'host') {
|
|
344
|
+
if (room.host !== ws) return;
|
|
345
|
+
room.host = null;
|
|
346
|
+
room.timer = setTimeout(() => closeRoom(room), hostGrace);
|
|
347
|
+
} else {
|
|
348
|
+
const s = room.slots[ws.slot - 1];
|
|
349
|
+
if (s.ws !== ws) return;
|
|
350
|
+
s.ws = null;
|
|
351
|
+
send(room.host, { t: 'leave', slot: ws.slot, reason: ws.leaveReason ?? (code === 1000 ? 'left' : 'disconnected') });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
wss.on('connection', (ws, req) => {
|
|
356
|
+
ws.seen = Date.now();
|
|
357
|
+
ws.failedJoins = 0;
|
|
358
|
+
ws.ip = clientIp(req);
|
|
359
|
+
ws.on('message', (raw, isBinary) => {
|
|
360
|
+
ws.seen = Date.now();
|
|
361
|
+
let m;
|
|
362
|
+
try {
|
|
363
|
+
m = isBinary ? null : JSON.parse(raw);
|
|
364
|
+
} catch {}
|
|
365
|
+
if (!isObj(m) || typeof m.t !== 'string') return send(ws, { t: 'error', code: 'bad-message', message: 'expected a JSON object with a string "t"' });
|
|
366
|
+
if (m.t === 'pong') return;
|
|
367
|
+
try {
|
|
368
|
+
if (!ws.role && m.t === 'create') onCreate(ws, m, req);
|
|
369
|
+
else if (!ws.role && m.t === 'join') onJoin(ws, m);
|
|
370
|
+
else if (!ws.role) throw new BadMessage("first message must be 'create' or 'join'");
|
|
371
|
+
else if (ws.role === 'host') onHost(ws, m);
|
|
372
|
+
else onPad(ws, m);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
if (!(err instanceof BadMessage)) console.error('joinstick:', err);
|
|
375
|
+
send(ws, { t: 'error', code: 'bad-message', message: err instanceof BadMessage ? err.message : 'internal error' });
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
ws.on('close', (code) => onClose(ws, code));
|
|
379
|
+
ws.on('error', () => {});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const pinger = setInterval(() => {
|
|
383
|
+
const now = Date.now();
|
|
384
|
+
for (const [ip, recent] of failedJoins) if (now - recent.since >= FAILED_WINDOW) failedJoins.delete(ip);
|
|
385
|
+
for (const ws of wss.clients) {
|
|
386
|
+
if (now - ws.seen > SILENCE) {
|
|
387
|
+
ws.leaveReason ??= 'timeout';
|
|
388
|
+
ws.terminate();
|
|
389
|
+
} else {
|
|
390
|
+
send(ws, { t: 'ping' });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}, PING_EVERY);
|
|
394
|
+
pinger.unref();
|
|
395
|
+
|
|
396
|
+
server.on('upgrade', (req, socket, head) => {
|
|
397
|
+
// Compare the raw path: a malformed URL must not be able to throw here.
|
|
398
|
+
if ((req.url ?? '').split('?')[0] === '/joinstick/ws') {
|
|
399
|
+
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
|
|
400
|
+
} else if (server.listenerCount('upgrade') === 1) {
|
|
401
|
+
socket.destroy();
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
async function serveFile(req, res, file, headers = {}) {
|
|
406
|
+
let body;
|
|
407
|
+
try {
|
|
408
|
+
if (!(await stat(file)).isFile()) return false;
|
|
409
|
+
body = await readFile(file);
|
|
410
|
+
} catch {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
const type = TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
|
|
414
|
+
res.writeHead(200, { 'content-type': type, 'cache-control': 'no-cache', ...headers });
|
|
415
|
+
res.end(req.method === 'HEAD' ? undefined : body);
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function serveStatic(req, res, url) {
|
|
420
|
+
let rel;
|
|
421
|
+
try {
|
|
422
|
+
rel = decodeURIComponent(url.pathname);
|
|
423
|
+
} catch {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
if (rel.split('/').some((part) => part.startsWith('.'))) return false; // .env, .git, ..
|
|
427
|
+
let file = path.resolve(staticDir, '.' + rel);
|
|
428
|
+
if (file !== staticDir && !file.startsWith(staticDir + path.sep)) return false;
|
|
429
|
+
if (rel.endsWith('/')) file = path.join(file, 'index.html');
|
|
430
|
+
else if ((await stat(path.join(file, 'index.html')).catch(() => null))?.isFile()) {
|
|
431
|
+
// A directory without its slash: /demo/game -> /demo/game/. A relative Location keeps
|
|
432
|
+
// a path like //host from turning into a redirect to another site.
|
|
433
|
+
res.writeHead(301, { location: `./${url.pathname.split('/').pop()}/${url.search}` }).end();
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
return serveFile(req, res, file);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function handle(req, res) {
|
|
440
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
|
441
|
+
let url;
|
|
442
|
+
try {
|
|
443
|
+
url = new URL(req.url, 'http://x');
|
|
444
|
+
} catch {
|
|
445
|
+
res.writeHead(400).end();
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
const p = url.pathname;
|
|
449
|
+
const cors = { 'access-control-allow-origin': '*' };
|
|
450
|
+
|
|
451
|
+
if (p.startsWith('/joinstick/') && SDK_FILES.has(p.slice(11))) return serveFile(req, res, path.join(PUBLIC, p.slice(11)), cors);
|
|
452
|
+
if (/^\/j(\/[A-Za-z]{4}(\/\d)?)?\/?$/.test(p)) return serveFile(req, res, path.join(PUBLIC, 'pad.html'));
|
|
453
|
+
if (p === '/llms.txt') return serveFile(req, res, path.join(ROOT, 'llms.txt'), cors);
|
|
454
|
+
if (p === '/joinstick/qr.svg') {
|
|
455
|
+
const room = rooms.get((url.searchParams.get('room') ?? '').toUpperCase());
|
|
456
|
+
const slot = url.searchParams.get('slot'); // no slot: the link gives the first free one
|
|
457
|
+
const n = Number(slot);
|
|
458
|
+
if (!room || (slot !== null && (!Number.isInteger(n) || n < 1 || n > room.slots.length))) return false;
|
|
459
|
+
res.writeHead(200, { 'content-type': 'image/svg+xml', 'cache-control': 'no-store', ...cors });
|
|
460
|
+
res.end(renderSVG(`${room.joinBase}/j/${room.code}${slot === null ? '' : `/${n}`}`, { border: 2 }));
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
if (staticDir) return serveStatic(req, res, url);
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Run our routes first and fall back to whatever handler the server already had.
|
|
468
|
+
const previous = server.listeners('request');
|
|
469
|
+
server.removeAllListeners('request');
|
|
470
|
+
server.on('request', async (req, res) => {
|
|
471
|
+
try {
|
|
472
|
+
if (await handle(req, res)) return;
|
|
473
|
+
} catch {
|
|
474
|
+
if (!res.headersSent) res.writeHead(500);
|
|
475
|
+
return res.end();
|
|
476
|
+
}
|
|
477
|
+
if (previous.length) for (const fn of previous) fn.call(server, req, res);
|
|
478
|
+
else res.writeHead(404, { 'content-type': 'text/plain' }).end('Not found');
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
close() {
|
|
483
|
+
closed = true;
|
|
484
|
+
clearInterval(pinger);
|
|
485
|
+
for (const room of rooms.values()) clearTimeout(room.timer);
|
|
486
|
+
rooms.clear();
|
|
487
|
+
for (const ws of wss.clients) ws.terminate();
|
|
488
|
+
wss.close();
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
}
|