joinstick 0.0.0-stage → 0.1.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 +373 -0
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/PROTOCOL.md +236 -0
- package/README.md +269 -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 +195 -0
- package/public/link.js +73 -0
- package/public/pad-layout.js +152 -0
- package/public/pad.css +268 -0
- package/public/pad.html +57 -0
- package/public/pad.js +344 -0
- package/src/cli.js +69 -0
- package/src/lan.js +34 -0
- package/src/server.d.ts +17 -0
- package/src/server.js +479 -0
package/public/pad.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// Default gamepad page served at /j and /j/ROOM/SLOT. Built on client.js.
|
|
2
|
+
import { join } from './client.js';
|
|
3
|
+
import { arrange, direction, edgeDistance, rectToPad, rotation, tapLength, toPad } from './pad-layout.js';
|
|
4
|
+
|
|
5
|
+
const COLOR = /^#[0-9a-f]{3,8}$/i;
|
|
6
|
+
const PALETTE = ['#e5484d', '#3e9bff', '#30a46c', '#f5a524', '#8e4ec6', '#e54d9e', '#12a594', '#f76b15'];
|
|
7
|
+
const $ = (id) => document.getElementById(id);
|
|
8
|
+
const reload = () => location.reload();
|
|
9
|
+
const go = (url) => () => location.assign(url);
|
|
10
|
+
|
|
11
|
+
// Keep the browser from zooming, selecting text or opening menus mid-game.
|
|
12
|
+
for (const ev of ['gesturestart', 'dblclick', 'contextmenu']) document.addEventListener(ev, (e) => e.preventDefault());
|
|
13
|
+
for (const ev of ['touchstart', 'touchmove']) $('pad').addEventListener(ev, (e) => e.preventDefault(), { passive: false });
|
|
14
|
+
|
|
15
|
+
function screen(title, text = '', actions = []) {
|
|
16
|
+
$('screen').hidden = false;
|
|
17
|
+
$('screen-title').textContent = title;
|
|
18
|
+
$('screen-text').textContent = text;
|
|
19
|
+
$('screen-actions').replaceChildren(
|
|
20
|
+
...actions.map(([label, fn]) => {
|
|
21
|
+
const b = document.createElement('button');
|
|
22
|
+
b.textContent = label;
|
|
23
|
+
b.onclick = fn;
|
|
24
|
+
return b;
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function showError(err, room, slot) {
|
|
30
|
+
$('pad').hidden = true;
|
|
31
|
+
const free = err.free ?? [];
|
|
32
|
+
const messages = {
|
|
33
|
+
'slot-taken': [`Player ${slot} is taken`, free.length ? 'Pick a free spot:' : 'The game is full.', free.map((n) => [`Player ${n}`, go(`/j/${room}/${n}`)])],
|
|
34
|
+
'room-not-found': ['Room not found', `There is no game with code ${room}. Check the code on the game screen.`, [['Enter another code', go('/j')]]],
|
|
35
|
+
'bad-slot': ['No such player', `Room ${room} has no player ${slot}.`, [['Take a free spot', go(`/j/${room}`)]]],
|
|
36
|
+
kicked: ['You were removed', 'The game disconnected this controller.', [['Join again', reload]]],
|
|
37
|
+
'room-closed': ['Game closed', 'The game has ended.', [['Join another game', go('/j')]]],
|
|
38
|
+
'rate-limited': ['Too many tries', 'Too many wrong codes from this network. Wait a minute and try again.', [['Retry', reload]]],
|
|
39
|
+
replaced: ['Opened somewhere else', `Player ${slot} is now controlled from another tab or phone.`, [['Take it back', reload]]],
|
|
40
|
+
unreachable: ["Can't reach the game", 'Make sure this phone is on the same Wi-Fi as the game.', [['Retry', reload]]],
|
|
41
|
+
};
|
|
42
|
+
screen(...(messages[err.code] ?? ['Something went wrong', String(err.code ?? err.message ?? err), [['Retry', reload]]]));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const [, roomParam, slotParam] = location.pathname.match(/^\/j(?:\/([A-Za-z]{4}))?(?:\/(\d))?/) ?? [];
|
|
46
|
+
if (roomParam) start(roomParam.toUpperCase(), slotParam ? Number(slotParam) : undefined);
|
|
47
|
+
else showForm();
|
|
48
|
+
|
|
49
|
+
function showForm() {
|
|
50
|
+
$('screen').hidden = true;
|
|
51
|
+
const form = $('form');
|
|
52
|
+
form.hidden = false;
|
|
53
|
+
form.onsubmit = (e) => {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
location.assign(`/j/${form.room.value.trim().toUpperCase()}/${form.slot.value}`);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function start(room, slot) {
|
|
60
|
+
screen('Connecting…', `Room ${room}`);
|
|
61
|
+
let pad;
|
|
62
|
+
try {
|
|
63
|
+
pad = await join({ room, slot, name: new URLSearchParams(location.search).get('name') ?? undefined });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return showError(err, room, slot);
|
|
66
|
+
}
|
|
67
|
+
history.replaceState(null, '', `/j/${pad.room}/${pad.slot}${location.search}`);
|
|
68
|
+
play(pad);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// iOS Safari has no navigator.vibrate, but toggling a native switch control
|
|
72
|
+
// plays the system haptic (iOS 18). Elsewhere this click does nothing visible.
|
|
73
|
+
function buzz(ms) {
|
|
74
|
+
if (!ms) return;
|
|
75
|
+
if (navigator.vibrate) navigator.vibrate(ms);
|
|
76
|
+
else $('haptic').click();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function play(pad) {
|
|
80
|
+
const main = $('pad');
|
|
81
|
+
const controls = $('controls');
|
|
82
|
+
const stickEl = $('stick');
|
|
83
|
+
const box = $('buttons');
|
|
84
|
+
const buttons = new Map(); // id -> element
|
|
85
|
+
const pointers = new Map(); // pointerId -> 'stick' | button id | null
|
|
86
|
+
let layout = pad.layout;
|
|
87
|
+
let stick = { x: 0, y: 0 };
|
|
88
|
+
let held = {};
|
|
89
|
+
let disabled = new Set();
|
|
90
|
+
let placed = []; // arranged gameplay buttons, in units
|
|
91
|
+
let unit = 0; // px per unit
|
|
92
|
+
let rot = 0;
|
|
93
|
+
let geo = null;
|
|
94
|
+
let layoutKey = '';
|
|
95
|
+
|
|
96
|
+
main.hidden = false;
|
|
97
|
+
$('screen').hidden = true;
|
|
98
|
+
$('code').textContent = pad.room;
|
|
99
|
+
$('slot').textContent = `P${pad.slot}`;
|
|
100
|
+
$('hero').textContent = `P${pad.slot}`;
|
|
101
|
+
|
|
102
|
+
const haptic = (id) => tapLength(layout, id);
|
|
103
|
+
|
|
104
|
+
function render() {
|
|
105
|
+
const key = JSON.stringify(pad.layout);
|
|
106
|
+
if (key === layoutKey) return;
|
|
107
|
+
layoutKey = key;
|
|
108
|
+
layout = pad.layout;
|
|
109
|
+
stickEl.hidden = layout.stick === 'none';
|
|
110
|
+
box.replaceChildren();
|
|
111
|
+
$('system').replaceChildren();
|
|
112
|
+
buttons.clear();
|
|
113
|
+
layout.buttons.forEach((b, i) => {
|
|
114
|
+
const el = document.createElement('div');
|
|
115
|
+
const text = document.createElement('span');
|
|
116
|
+
text.textContent = b.label; // layout comes from the host: text only, never HTML
|
|
117
|
+
el.append(text);
|
|
118
|
+
if (b.system) {
|
|
119
|
+
el.className = 'sys';
|
|
120
|
+
bindSystem(el, b.id);
|
|
121
|
+
$('system').append(el);
|
|
122
|
+
} else {
|
|
123
|
+
el.className = 'btn';
|
|
124
|
+
el.style.setProperty('--c', COLOR.test(b.color ?? '') ? b.color : PALETTE[i % PALETTE.length]);
|
|
125
|
+
box.append(el);
|
|
126
|
+
}
|
|
127
|
+
buttons.set(b.id, el);
|
|
128
|
+
});
|
|
129
|
+
release();
|
|
130
|
+
place();
|
|
131
|
+
applyState(pad.state, {});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Rotate the pad if the viewport has the wrong orientation, then size and
|
|
135
|
+
// place the buttons in the space the stick leaves.
|
|
136
|
+
function place() {
|
|
137
|
+
const w = innerWidth;
|
|
138
|
+
const h = innerHeight;
|
|
139
|
+
rot = rotation(layout.orientation ?? 'landscape', w, h);
|
|
140
|
+
main.style.setProperty('--vw', `${w}px`);
|
|
141
|
+
main.style.setProperty('--vh', `${h}px`);
|
|
142
|
+
main.classList.toggle('rot90', rot === 90);
|
|
143
|
+
main.classList.toggle('rot-90', rot === -90);
|
|
144
|
+
const tall = (rot ? w : h) > (rot ? h : w);
|
|
145
|
+
main.classList.toggle('tall', tall);
|
|
146
|
+
|
|
147
|
+
const { items, width, height } = arrange(layout.buttons.filter((b) => !b.system), layout.arrangement, tall);
|
|
148
|
+
box.style.width = box.style.height = '0';
|
|
149
|
+
const cs = getComputedStyle(controls);
|
|
150
|
+
const px = (v) => parseFloat(v) || 0;
|
|
151
|
+
const availW = controls.clientWidth - px(cs.paddingLeft) - px(cs.paddingRight) - (stickEl.hidden ? 0 : stickEl.offsetWidth + px(cs.columnGap));
|
|
152
|
+
const availH = tall ? main.clientHeight * 0.42 : controls.clientHeight - px(cs.paddingTop) - px(cs.paddingBottom);
|
|
153
|
+
unit = Math.max(0, Math.min(Math.min(w, h) * 0.24, 100, availW / (width || 1), availH / (height || 1)));
|
|
154
|
+
box.style.width = `${width * unit}px`;
|
|
155
|
+
box.style.height = `${height * unit}px`;
|
|
156
|
+
for (const it of items) {
|
|
157
|
+
const el = buttons.get(it.id);
|
|
158
|
+
Object.assign(el.style, {
|
|
159
|
+
left: `${(it.x - it.w / 2) * unit}px`,
|
|
160
|
+
top: `${(it.y - it.h / 2) * unit}px`,
|
|
161
|
+
width: `${it.w * unit}px`,
|
|
162
|
+
height: `${it.h * unit}px`,
|
|
163
|
+
});
|
|
164
|
+
// Shrink the label until it fits inside the button (labels are ≤ 8 chars).
|
|
165
|
+
let size = Math.round(0.28 * unit * Math.max(1, Math.min(it.w, it.h)));
|
|
166
|
+
el.style.fontSize = `${size}px`;
|
|
167
|
+
while (el.firstChild.offsetWidth > el.clientWidth * 0.78 && size > 9) el.style.fontSize = `${--size}px`;
|
|
168
|
+
}
|
|
169
|
+
placed = items;
|
|
170
|
+
geo = null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function applyState(state, patch) {
|
|
174
|
+
$('title').textContent = state.title ?? '';
|
|
175
|
+
main.style.setProperty('--accent', COLOR.test(state.color ?? '') ? state.color : '');
|
|
176
|
+
const glow = new Set(state.highlight ?? []);
|
|
177
|
+
disabled = new Set(state.disabled ?? []);
|
|
178
|
+
for (const [id, el] of buttons) {
|
|
179
|
+
el.classList.toggle('glow', glow.has(id) && !disabled.has(id)); // disabled wins
|
|
180
|
+
el.classList.toggle('off', disabled.has(id));
|
|
181
|
+
}
|
|
182
|
+
if (patch.vibrate) buzz(patch.vibrate);
|
|
183
|
+
else if (patch.highlight?.length) buzz(40);
|
|
184
|
+
update();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Everything in pad coordinates, so a rotated pad needs no special cases.
|
|
188
|
+
function measure() {
|
|
189
|
+
const [w, h] = [innerWidth, innerHeight];
|
|
190
|
+
const s = rectToPad(stickEl.getBoundingClientRect(), rot, w, h);
|
|
191
|
+
const b = rectToPad(box.getBoundingClientRect(), rot, w, h);
|
|
192
|
+
return {
|
|
193
|
+
stick: { x: s.left + s.width / 2, y: s.top + s.height / 2, r: s.width / 2 },
|
|
194
|
+
split: stickEl.hidden ? -Infinity : (s.left + s.width + b.left) / 2,
|
|
195
|
+
buttons: placed.map((it) => ({ id: it.id, x: b.left + it.x * unit, y: b.top + it.y * unit, w: it.w * unit, h: it.h * unit })),
|
|
196
|
+
// Larger than half of any gap between buttons: no dead spots between them.
|
|
197
|
+
slack: Math.max(16, unit / 2),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const point = (e) => toPad(e.clientX, e.clientY, rot, innerWidth, innerHeight);
|
|
202
|
+
|
|
203
|
+
// Nearest enabled button under (or near) the finger.
|
|
204
|
+
function hit(p) {
|
|
205
|
+
let best = null;
|
|
206
|
+
let bestD = geo.slack;
|
|
207
|
+
for (const b of geo.buttons) {
|
|
208
|
+
const d = edgeDistance(p.x, p.y, b);
|
|
209
|
+
if (d < bestD && !disabled.has(b.id)) [best, bestD] = [b.id, d];
|
|
210
|
+
}
|
|
211
|
+
return best;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function moveStick(p) {
|
|
215
|
+
const { x, y, r } = geo.stick;
|
|
216
|
+
const dx = p.x - x;
|
|
217
|
+
const dy = p.y - y;
|
|
218
|
+
const next = Math.hypot(dx, dy) < r * 0.15 ? { x: 0, y: 0 } : direction((Math.atan2(-dy, dx) * 180) / Math.PI);
|
|
219
|
+
if ((next.x || next.y) && (next.x !== stick.x || next.y !== stick.y)) buzz(haptic('stick'));
|
|
220
|
+
stick = next;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function update() {
|
|
224
|
+
const next = {};
|
|
225
|
+
for (const id of buttons.keys()) next[id] = false;
|
|
226
|
+
for (const v of pointers.values()) if (v && v !== 'stick' && !disabled.has(v)) next[v] = true;
|
|
227
|
+
for (const [id, el] of buttons) {
|
|
228
|
+
if (next[id] && !held[id]) buzz(haptic(id));
|
|
229
|
+
el.classList.toggle('down', next[id]);
|
|
230
|
+
}
|
|
231
|
+
held = next;
|
|
232
|
+
for (const [dir, on] of [['up', stick.y < 0], ['down', stick.y > 0], ['left', stick.x < 0], ['right', stick.x > 0]]) {
|
|
233
|
+
stickEl.querySelector(`.${dir}`).classList.toggle('on', on);
|
|
234
|
+
}
|
|
235
|
+
pad.setInput({ x: stick.x, y: stick.y, buttons: held });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function release() {
|
|
239
|
+
pointers.clear();
|
|
240
|
+
stick = { x: 0, y: 0 };
|
|
241
|
+
update();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// System buttons (START, MENU...) sit in the top bar, away from the thumbs.
|
|
245
|
+
function bindSystem(el, id) {
|
|
246
|
+
el.addEventListener('pointerdown', (e) => {
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
pointers.set(e.pointerId, id);
|
|
249
|
+
update();
|
|
250
|
+
});
|
|
251
|
+
// Touch pointers stay captured by the pill; a mouse releases it on leaving.
|
|
252
|
+
for (const ev of ['pointerup', 'pointercancel', 'pointerleave']) {
|
|
253
|
+
el.addEventListener(ev, (e) => pointers.delete(e.pointerId) && update());
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
controls.addEventListener('pointerdown', (e) => {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
geo ??= measure();
|
|
260
|
+
const p = point(e);
|
|
261
|
+
const id = hit(p);
|
|
262
|
+
const stickFree = ![...pointers.values()].includes('stick');
|
|
263
|
+
if (id) pointers.set(e.pointerId, id);
|
|
264
|
+
else if (stickFree && p.x < geo.split) {
|
|
265
|
+
pointers.set(e.pointerId, 'stick');
|
|
266
|
+
moveStick(p);
|
|
267
|
+
} else pointers.set(e.pointerId, null);
|
|
268
|
+
update();
|
|
269
|
+
});
|
|
270
|
+
controls.addEventListener('pointermove', (e) => {
|
|
271
|
+
if (!pointers.has(e.pointerId)) return;
|
|
272
|
+
if (pointers.get(e.pointerId) === 'stick') moveStick(point(e));
|
|
273
|
+
else pointers.set(e.pointerId, hit(point(e))); // sliding between buttons
|
|
274
|
+
update();
|
|
275
|
+
});
|
|
276
|
+
for (const ev of ['pointerup', 'pointercancel']) {
|
|
277
|
+
controls.addEventListener(ev, (e) => {
|
|
278
|
+
if (pointers.get(e.pointerId) === 'stick') stick = { x: 0, y: 0 };
|
|
279
|
+
pointers.delete(e.pointerId);
|
|
280
|
+
update();
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
addEventListener('resize', () => {
|
|
284
|
+
release();
|
|
285
|
+
place();
|
|
286
|
+
});
|
|
287
|
+
addEventListener('blur', release);
|
|
288
|
+
addEventListener('pagehide', release);
|
|
289
|
+
document.addEventListener('touchcancel', release);
|
|
290
|
+
document.addEventListener('visibilitychange', () => document.hidden && release());
|
|
291
|
+
|
|
292
|
+
// Where the browser allows it (Android Chrome), the first tap on a rotated
|
|
293
|
+
// pad goes fullscreen and locks the orientation; CSS rotation covers the rest.
|
|
294
|
+
let lockTried = false;
|
|
295
|
+
main.addEventListener(
|
|
296
|
+
'pointerdown',
|
|
297
|
+
() => {
|
|
298
|
+
if (lockTried || !rot || !matchMedia('(pointer: coarse)').matches) return;
|
|
299
|
+
lockTried = true;
|
|
300
|
+
const root = document.documentElement;
|
|
301
|
+
if (!root.requestFullscreen || !screen.orientation?.lock) return;
|
|
302
|
+
root
|
|
303
|
+
.requestFullscreen({ navigationUI: 'hide' })
|
|
304
|
+
.then(() => screen.orientation.lock(rot === 90 ? 'landscape' : 'portrait'))
|
|
305
|
+
.catch(() => {});
|
|
306
|
+
},
|
|
307
|
+
{ capture: true },
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// Leaving is quick but not accidental: hold the button for 600 ms.
|
|
311
|
+
const leave = $('leave');
|
|
312
|
+
let leaveTimer;
|
|
313
|
+
leave.addEventListener('pointerdown', () => {
|
|
314
|
+
leave.classList.add('holding');
|
|
315
|
+
leaveTimer = setTimeout(() => {
|
|
316
|
+
release();
|
|
317
|
+
pad.leave();
|
|
318
|
+
buzz(60);
|
|
319
|
+
main.hidden = true;
|
|
320
|
+
screen('You left', `Player ${pad.slot} is free now.`, [['Join again', reload]]);
|
|
321
|
+
}, 600);
|
|
322
|
+
});
|
|
323
|
+
for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) {
|
|
324
|
+
leave.addEventListener(ev, () => {
|
|
325
|
+
clearTimeout(leaveTimer);
|
|
326
|
+
leave.classList.remove('holding');
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// A resumed host may send a new layout (as a fresh 'joined') with the pad state.
|
|
331
|
+
pad.on('pad', (state, patch) => {
|
|
332
|
+
render();
|
|
333
|
+
applyState(state, patch);
|
|
334
|
+
});
|
|
335
|
+
pad.on('error', (err) => showError(err, pad.room, pad.slot));
|
|
336
|
+
pad.on('status', (s) => {
|
|
337
|
+
if (s === 'reconnecting') screen('Reconnecting…', 'Getting back into the game.');
|
|
338
|
+
if (s === 'connected') {
|
|
339
|
+
render();
|
|
340
|
+
$('screen').hidden = true;
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
render();
|
|
344
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { attach } from './server.js';
|
|
6
|
+
import { lanAddresses } from './lan.js';
|
|
7
|
+
|
|
8
|
+
const HELP = `Usage: joinstick [options]
|
|
9
|
+
|
|
10
|
+
Turn phones into gamepads for browser games.
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--port <n> Port to listen on (default: $PORT or 3000)
|
|
14
|
+
--static <dir> Also serve this directory (your game) on the same port
|
|
15
|
+
--public-url <url> Base URL phones should open, e.g. https://abc.trycloudflare.com
|
|
16
|
+
(default: $PUBLIC_URL, else the address the game page was
|
|
17
|
+
opened with, else the detected LAN address)
|
|
18
|
+
-v, --version Print the version
|
|
19
|
+
-h, --help Show this help
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
let args;
|
|
23
|
+
try {
|
|
24
|
+
args = parseArgs({
|
|
25
|
+
options: {
|
|
26
|
+
port: { type: 'string' },
|
|
27
|
+
static: { type: 'string' },
|
|
28
|
+
'public-url': { type: 'string' },
|
|
29
|
+
help: { type: 'boolean', short: 'h' },
|
|
30
|
+
version: { type: 'boolean', short: 'v' },
|
|
31
|
+
},
|
|
32
|
+
}).values;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error(`${err.message}\n\n${HELP}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
if (args.version) {
|
|
38
|
+
console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
if (args.help) {
|
|
42
|
+
console.log(HELP);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const port = Number(args.port ?? process.env.PORT ?? 3000);
|
|
47
|
+
const publicUrl = args['public-url'] ?? process.env.PUBLIC_URL;
|
|
48
|
+
const server = http.createServer();
|
|
49
|
+
attach(server, { static: args.static, publicUrl });
|
|
50
|
+
|
|
51
|
+
server.on('error', (err) => {
|
|
52
|
+
console.error(err.code === 'EADDRINUSE' ? `Port ${port} is already in use. Try --port ${port + 1}` : err.message);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
server.listen(port, () => {
|
|
57
|
+
const lan = lanAddresses();
|
|
58
|
+
const base = publicUrl?.replace(/\/+$/, '') ?? (lan[0] ? `http://${lan[0].address}:${port}` : `http://localhost:${port}`);
|
|
59
|
+
console.log(`Joinstick listening on http://localhost:${port}`);
|
|
60
|
+
if (lan.length) {
|
|
61
|
+
console.log('LAN addresses:');
|
|
62
|
+
for (const a of lan) console.log(` ${a.address.padEnd(15)} ${a.name}${a === lan[0] && !publicUrl ? ' <- used for join links' : ''}`);
|
|
63
|
+
} else {
|
|
64
|
+
console.log('No LAN address found: phones can only join with --public-url.');
|
|
65
|
+
}
|
|
66
|
+
console.log(`Phones join at: ${base}/j`);
|
|
67
|
+
console.log(`Host SDK: http://localhost:${port}/joinstick/host.js`);
|
|
68
|
+
if (args.static) console.log(`Game: http://localhost:${port}/ (serving ${args.static})`);
|
|
69
|
+
});
|
package/src/lan.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
|
|
3
|
+
const VIRTUAL = /^(docker|br-|veth|virbr|vmnet|vboxnet|utun|tun|tap|wg|zt|bridge|vethernet|virtualbox|vmware|hyper-v)/i;
|
|
4
|
+
|
|
5
|
+
// Prefer typical home/office ranges: 192.168/16, then 10/8, then 172.16/12.
|
|
6
|
+
function rank(ip) {
|
|
7
|
+
if (ip.startsWith('192.168.')) return 0;
|
|
8
|
+
if (ip.startsWith('10.')) return 1;
|
|
9
|
+
const [a, b] = ip.split('.').map(Number);
|
|
10
|
+
if (a === 172 && b >= 16 && b <= 31) return 2;
|
|
11
|
+
return 3;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function skipped(ip) {
|
|
15
|
+
const [a, b] = ip.split('.').map(Number);
|
|
16
|
+
return (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** LAN IPv4 addresses phones can likely reach, best candidate first. */
|
|
20
|
+
export function lanAddresses() {
|
|
21
|
+
const found = [];
|
|
22
|
+
for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
|
|
23
|
+
if (VIRTUAL.test(name)) continue;
|
|
24
|
+
for (const a of addrs ?? []) {
|
|
25
|
+
if (a.family !== 'IPv4' || a.internal || skipped(a.address)) continue;
|
|
26
|
+
found.push({ name, address: a.address });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return found.sort((x, y) => rank(x.address) - rank(y.address));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isLoopback(hostname) {
|
|
33
|
+
return hostname === 'localhost' || hostname === '::1' || hostname === '0.0.0.0' || hostname.startsWith('127.');
|
|
34
|
+
}
|
package/src/server.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Server } from 'node:http';
|
|
2
|
+
|
|
3
|
+
export interface AttachOptions {
|
|
4
|
+
/** Also serve this directory (your game) for paths Joinstick does not handle. */
|
|
5
|
+
static?: string;
|
|
6
|
+
/** Origin phones should open, e.g. 'https://play.example.com'. Default: the request's host, else the LAN address. */
|
|
7
|
+
publicUrl?: string;
|
|
8
|
+
/** How long a room waits for its game page to come back before closing, in ms. Default 60000. */
|
|
9
|
+
hostGraceMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Add Joinstick to an existing Node HTTP server: the WebSocket endpoint, the
|
|
14
|
+
* pad page at /j, the browser SDKs at /joinstick/*, QR codes and /llms.txt.
|
|
15
|
+
* Existing 'request' listeners keep working for every other path.
|
|
16
|
+
*/
|
|
17
|
+
export function attach(server: Server, opts?: AttachOptions): { close(): void };
|