joinstick 0.1.0 → 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 +10 -9
- package/CHANGELOG.md +29 -0
- package/PROTOCOL.md +4 -2
- package/README.md +20 -7
- package/llms.txt +4 -4
- package/package.json +1 -1
- package/public/host.d.ts +4 -4
- package/public/host.js +7 -2
- package/public/pad.css +3 -1
- package/public/pad.html +2 -2
- package/public/pad.js +3 -2
- package/src/cli.js +7 -1
- package/src/server.d.ts +6 -0
- package/src/server.js +24 -12
package/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Joinstick for coding agents
|
|
2
2
|
|
|
3
3
|
Joinstick turns phones into gamepads for browser games. The game page (the
|
|
4
|
-
**host**) shows
|
|
5
|
-
|
|
6
|
-
contract. Wire format: [PROTOCOL.md](PROTOCOL.md). Types:
|
|
4
|
+
**host**) shows a QR code; each player scans it and their phone becomes a
|
|
5
|
+
controller in the first free slot (per-slot QR codes exist too). Read this
|
|
6
|
+
file before integrating; it is the whole contract. Wire format: [PROTOCOL.md](PROTOCOL.md). Types:
|
|
7
7
|
[`public/host.d.ts`](public/host.d.ts), [`public/client.d.ts`](public/client.d.ts).
|
|
8
8
|
|
|
9
9
|
## 1. Run the server
|
|
@@ -25,7 +25,7 @@ something else (see "Game served elsewhere" below).
|
|
|
25
25
|
import { host } from '/joinstick/host.js';
|
|
26
26
|
|
|
27
27
|
const room = await host({ slots: 2, layout: { stick: 'dpad', buttons: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] } });
|
|
28
|
-
|
|
28
|
+
showQr(room.qr(), room.joinUrl()); // <img src=qr> + the URL as text; each phone gets the first free slot (room.qr(n): a fixed slot)
|
|
29
29
|
|
|
30
30
|
function tick() {
|
|
31
31
|
for (let n = 1; n <= room.slots; n++) {
|
|
@@ -185,8 +185,8 @@ room.code // 'KQZX' (4 consonants)
|
|
|
185
185
|
room.slots; room.status // 'connecting' | 'connected' | 'reconnecting' | 'closed'
|
|
186
186
|
room.input(n) // see section 4
|
|
187
187
|
room.connected(n) // boolean
|
|
188
|
-
room.qr(n)
|
|
189
|
-
room.joinUrl(n)
|
|
188
|
+
room.qr(n?) // URL of an SVG QR code for <img src>; no n: first free slot
|
|
189
|
+
room.joinUrl(n?) // the URL inside the QR: show it as text too
|
|
190
190
|
room.pad(n, patch) // section 6
|
|
191
191
|
room.kick(n) // disconnect that phone; it gets a "removed" screen
|
|
192
192
|
room.send(n, data) // any JSON to one phone (custom controllers)
|
|
@@ -218,7 +218,7 @@ new tab resumes the same room and the original tab gets an `error` event with
|
|
|
218
218
|
## 8. CLI and embedding
|
|
219
219
|
|
|
220
220
|
```
|
|
221
|
-
joinstick [--port 3000] [--static ./dir] [--public-url URL]
|
|
221
|
+
joinstick [--port 3000] [--static ./dir] [--public-url URL] [--client-ip-header NAME]
|
|
222
222
|
```
|
|
223
223
|
|
|
224
224
|
| Flag | Env | Purpose |
|
|
@@ -226,6 +226,7 @@ joinstick [--port 3000] [--static ./dir] [--public-url URL]
|
|
|
226
226
|
| `--port` | `PORT` | Port (default 3000). |
|
|
227
227
|
| `--static <dir>` | | Serve your game from the same server (recommended). |
|
|
228
228
|
| `--public-url <url>` | `PUBLIC_URL` | Base URL phones should open (tunnels, Docker, reverse proxies). Must be an origin like `https://abc.example.com`, without a path: Joinstick's routes live at the root. |
|
|
229
|
+
| `--client-ip-header <name>` | `CLIENT_IP_HEADER` | Behind a reverse proxy: the header it sets to the visitor's address (`fly-client-ip`, `x-real-ip`; first value used). The 20-rooms and failed-join limits per address then use it. Never set it without a proxy that overwrites the header: clients can fake it. `attach()` option: `clientIpHeader`. |
|
|
229
230
|
|
|
230
231
|
The join URL base is chosen as: `--public-url`, else the `Host` header the
|
|
231
232
|
game used if it is not loopback, else the detected LAN IPv4 (192.168.x
|
|
@@ -254,8 +255,8 @@ Append `?name=Ana` to a join URL to give the player a name (shown to the host
|
|
|
254
255
|
in the `join` event).
|
|
255
256
|
|
|
256
257
|
Routes: WebSocket `/joinstick/ws`; SDKs `/joinstick/host.js`,
|
|
257
|
-
`/joinstick/client.js` (CORS `*`); pad page `/j` and `/j/ROOM/SLOT`; QR
|
|
258
|
-
`/joinstick/qr.svg?room=&slot=` (existing rooms only); `/llms.txt`.
|
|
258
|
+
`/joinstick/client.js` (CORS `*`); pad page `/j`, `/j/ROOM` (first free slot) and `/j/ROOM/SLOT`; QR
|
|
259
|
+
`/joinstick/qr.svg?room=&slot=` (existing rooms only, slot optional); `/llms.txt`.
|
|
259
260
|
|
|
260
261
|
### Game served elsewhere (Vite, another port)
|
|
261
262
|
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ All notable changes to this project are documented here. The format follows
|
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the version
|
|
6
6
|
is 0.x, a minor release may change the API; the changelog says how.
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.0] - 2026-10-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- One QR for everyone: `room.qr()` and `room.joinUrl()` without a slot give a
|
|
15
|
+
link that puts each phone in the first free slot (`/j/ROOM`);
|
|
16
|
+
`/joinstick/qr.svg` accepts a missing `slot`.
|
|
17
|
+
- `examples/paint-party`: a party game for 1-8 phones.
|
|
18
|
+
- `--client-ip-header <name>` (`CLIENT_IP_HEADER`, `attach()` option
|
|
19
|
+
`clientIpHeader`): behind a reverse proxy, the per-address room cap and
|
|
20
|
+
failed-join limit use the header the proxy sets instead of the proxy's
|
|
21
|
+
address. Off by default, since clients can send the header themselves.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- The player number on the pad's join form is optional; blank takes the first
|
|
26
|
+
free slot.
|
|
27
|
+
- The pad's top bar is tinted and underlined in the pad color, so players can
|
|
28
|
+
match their phone to the screen at a glance.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- `--static` redirects a directory requested without its trailing slash
|
|
33
|
+
(`/demo/game` -> `/demo/game/`, query kept) instead of answering 404.
|
|
34
|
+
|
|
8
35
|
## [0.1.0] - 2026-10-04
|
|
9
36
|
|
|
10
37
|
First public release.
|
|
@@ -32,4 +59,6 @@ First public release.
|
|
|
32
59
|
- JSON/WebSocket protocol v1 (PROTOCOL.md), AGENTS.md for coding agents,
|
|
33
60
|
TypeScript declarations, Dockerfile.
|
|
34
61
|
|
|
62
|
+
[Unreleased]: https://github.com/raprav/joinstick/compare/v0.2.0...HEAD
|
|
63
|
+
[0.2.0]: https://github.com/raprav/joinstick/compare/v0.1.0...v0.2.0
|
|
35
64
|
[0.1.0]: https://github.com/raprav/joinstick/releases/tag/v0.1.0
|
package/PROTOCOL.md
CHANGED
|
@@ -100,8 +100,10 @@ The pad gets fatal `kicked`; the host gets `leave` with reason `kicked`.
|
|
|
100
100
|
{"t":"created","room":"KQZX","hostToken":"9f…32 hex","joinBase":"http://192.168.1.20:3000","layout":{…validated…},"slots":[{"slot":1,"connected":false,"name":""}]}
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
Pads join at `${joinBase}/j/${room}/${slot}
|
|
104
|
-
|
|
103
|
+
Pads join at `${joinBase}/j/${room}/${slot}`, or at `${joinBase}/j/${room}` to
|
|
104
|
+
get the first free slot. The QR for a slot is
|
|
105
|
+
`/joinstick/qr.svg?room=KQZX&slot=1` on the Joinstick server; without `slot`
|
|
106
|
+
it encodes the slot-less link.
|
|
105
107
|
|
|
106
108
|
### `join`
|
|
107
109
|
|
package/README.md
CHANGED
|
@@ -10,7 +10,12 @@ for local multiplayer and party games on a laptop or TV.
|
|
|
10
10
|
[](package.json)
|
|
11
11
|
[](https://github.com/raprav/joinstick/actions/workflows/ci.yml)
|
|
12
12
|
|
|
13
|
-
<p align="center"><img src="https://raw.githubusercontent.com/raprav/joinstick/main/docs/hero.png" alt="Left:
|
|
13
|
+
<p align="center"><a href="https://joinstick.fly.dev/demo/paint-party"><img src="https://raw.githubusercontent.com/raprav/joinstick/main/docs/hero.png" alt="Left: Paint Party on a TV mid-round, four paint blobs covering the floor and one QR code in the corner that every player scans. Right: a phone held sideways showing player 1's orange pad with a d-pad and BOMB and DASH buttons." width="800"></a></p>
|
|
14
|
+
|
|
15
|
+
**[Live demo: Paint Party](https://joinstick.fly.dev/demo/paint-party)**. Open it on a big screen and scan the QR
|
|
16
|
+
with phones: one QR for everyone, drop in or out anytime, 1-8 players, haptics
|
|
17
|
+
on Android. Its source is a single file in
|
|
18
|
+
[`examples/paint-party`](examples/paint-party) that uses only the public SDK.
|
|
14
19
|
|
|
15
20
|
## Quick start
|
|
16
21
|
|
|
@@ -23,7 +28,7 @@ Save this as `my-game/index.html`:
|
|
|
23
28
|
<script type="module">
|
|
24
29
|
import { host } from '/joinstick/host.js';
|
|
25
30
|
const room = await host({ slots: 2 }); // default pad: d-pad + A, B
|
|
26
|
-
document.getElementById('qr').src = room.qr(
|
|
31
|
+
document.getElementById('qr').src = room.qr(); // every player scans this
|
|
27
32
|
(function frame() {
|
|
28
33
|
const p1 = room.input(1); // { x, y, buttons, pressed }, never undefined
|
|
29
34
|
if (p1.pressed.a) console.log('jump!'); // x, y in [-1, 1]; pressed = went down since last read
|
|
@@ -42,6 +47,10 @@ Open `http://localhost:3000` on the computer, scan the QR with a phone on the
|
|
|
42
47
|
same Wi-Fi, and press A. Embedding in your own Node server or using
|
|
43
48
|
TypeScript: `npm i joinstick`. A complete example game is in
|
|
44
49
|
[`examples/basic`](examples/basic).
|
|
50
|
+
[`examples/paint-party`](examples/paint-party) is a party game for 1-8 phones
|
|
51
|
+
that join and leave at any time through one QR code
|
|
52
|
+
([play it live](https://joinstick.fly.dev/demo/paint-party): open it on a big
|
|
53
|
+
screen, then scan the QR with phones).
|
|
45
54
|
|
|
46
55
|
### Using a bundler or your own dev server
|
|
47
56
|
|
|
@@ -73,8 +82,9 @@ needs HTTPS too (see Self-hosting).
|
|
|
73
82
|
└──────────────────┘
|
|
74
83
|
```
|
|
75
84
|
|
|
76
|
-
The game creates a room (`KQZX`) with up to 8 player slots.
|
|
77
|
-
|
|
85
|
+
The game creates a room (`KQZX`) with up to 8 player slots. One QR code opens
|
|
86
|
+
`/j/KQZX` on any phone, which takes the first free slot (`/j/KQZX/<slot>` asks
|
|
87
|
+
for a specific one). The phone sends its controller
|
|
78
88
|
state on every change; your game reads `room.input(n)` once per frame. All
|
|
79
89
|
traffic goes through your server; nothing leaves your network unless you put
|
|
80
90
|
it on the internet.
|
|
@@ -130,7 +140,7 @@ a button, tap another). Full schema in [AGENTS.md](AGENTS.md#5-layout).
|
|
|
130
140
|
|---|---|
|
|
131
141
|
| `host({ slots, layout, server })` | Create (or after a reload, resume) a room. `server` is the Joinstick URL when the game is served elsewhere. |
|
|
132
142
|
| `room.input(n)` | `{ x, y, buttons, pressed }` for player `n`. Read it every frame. |
|
|
133
|
-
| `room.qr(n)`, `room.joinUrl(n)` | QR image URL and plain join link for player `n`. |
|
|
143
|
+
| `room.qr(n?)`, `room.joinUrl(n?)` | QR image URL and plain join link for player `n`. Without `n`: one link for everyone, each phone gets the first free slot. |
|
|
134
144
|
| `room.connected(n)`, `room.on('join' \| 'leave', fn)` | Who is here; pause when someone drops. |
|
|
135
145
|
| `room.pad(n, { title, color, highlight, disabled, vibrate })` | Change what a phone shows. |
|
|
136
146
|
| `room.send(n, data)`, `room.broadcast(data)`, `room.on('message', fn)` | Custom messages both ways. |
|
|
@@ -149,7 +159,7 @@ phones): [AGENTS.md](AGENTS.md).
|
|
|
149
159
|
## CLI
|
|
150
160
|
|
|
151
161
|
```
|
|
152
|
-
joinstick [--port 3000] [--static ./dir] [--public-url URL] [--version]
|
|
162
|
+
joinstick [--port 3000] [--static ./dir] [--public-url URL] [--client-ip-header NAME] [--version]
|
|
153
163
|
```
|
|
154
164
|
|
|
155
165
|
| Flag | Env | |
|
|
@@ -157,6 +167,7 @@ joinstick [--port 3000] [--static ./dir] [--public-url URL] [--version]
|
|
|
157
167
|
| `--port` | `PORT` | Port to listen on. Default 3000. |
|
|
158
168
|
| `--static <dir>` | | Also serve your game from this directory. |
|
|
159
169
|
| `--public-url <url>` | `PUBLIC_URL` | Origin that QR codes and join links use (no path). Default: the host the game page connected with, unless it is `localhost`/loopback; then the detected LAN address. |
|
|
170
|
+
| `--client-ip-header <name>` | `CLIENT_IP_HEADER` | Header a reverse proxy sets to the visitor's address (`fly-client-ip`, `x-real-ip`). The per-address limits use it instead of the proxy's address. Only set it behind a proxy that overwrites the header. |
|
|
160
171
|
| `-v`, `--version` | | Print the version. |
|
|
161
172
|
|
|
162
173
|
## Self-hosting
|
|
@@ -168,7 +179,9 @@ joinstick [--port 3000] [--static ./dir] [--public-url URL] [--version]
|
|
|
168
179
|
`/joinstick/ws`, and start Joinstick with `--public-url https://your.domain`
|
|
169
180
|
so QR codes point at the public address. Behind a proxy every game shares
|
|
170
181
|
the proxy's IP address, so the 20-rooms-per-IP cap applies to all of them
|
|
171
|
-
together
|
|
182
|
+
together unless you pass `--client-ip-header` with a header your proxy
|
|
183
|
+
overwrites with the visitor's address (Fly.io: `fly-client-ip`).
|
|
184
|
+
With Caddy (which passes WebSockets through by default):
|
|
172
185
|
|
|
173
186
|
```
|
|
174
187
|
your.domain {
|
package/llms.txt
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
# Joinstick
|
|
2
2
|
|
|
3
|
-
> Turn phones into gamepads for browser games. The game page (host) shows one QR code per
|
|
3
|
+
> Turn phones into gamepads for browser games. The game page (host) shows one QR code for everyone (or one per slot); scanning it opens a controller page on the phone in the first free slot. Self-hosted Node server (`npx joinstick`), plain ESM browser SDKs, JSON over WebSocket.
|
|
4
4
|
|
|
5
5
|
Full guide: AGENTS.md. Wire format: PROTOCOL.md. Types: /joinstick/host.d.ts, /joinstick/client.d.ts (all in the npm package and at https://github.com/raprav/joinstick).
|
|
6
6
|
|
|
7
7
|
## Run
|
|
8
8
|
|
|
9
|
-
`npx joinstick --static ./my-game` serves the game and Joinstick on http://localhost:3000 (or `npm i joinstick`, then `npx joinstick`). Node 22+. Phones must be on the same Wi-Fi. Flags: `--port` (PORT, default 3000), `--static <dir>`, `--public-url <url>` (PUBLIC_URL; base URL phones open, an origin without a path, for tunnels/Docker/proxies).
|
|
9
|
+
`npx joinstick --static ./my-game` serves the game and Joinstick on http://localhost:3000 (or `npm i joinstick`, then `npx joinstick`). Node 22+. Phones must be on the same Wi-Fi. Flags: `--port` (PORT, default 3000), `--static <dir>`, `--public-url <url>` (PUBLIC_URL; base URL phones open, an origin without a path, for tunnels/Docker/proxies), `--client-ip-header <name>` (CLIENT_IP_HEADER; behind a trusted proxy, the header with the visitor's address for the per-address limits).
|
|
10
10
|
|
|
11
11
|
## Integrate
|
|
12
12
|
|
|
13
13
|
```js
|
|
14
14
|
import { host } from '/joinstick/host.js';
|
|
15
15
|
const room = await host({ slots: 2, layout: { stick: 'dpad', buttons: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] } });
|
|
16
|
-
// show <img src={room.qr(
|
|
16
|
+
// show <img src={room.qr()}> and the text room.joinUrl(): each phone that scans it gets the first free slot (room.qr(n) for a fixed slot)
|
|
17
17
|
function tick() {
|
|
18
18
|
for (let n = 1; n <= room.slots; n++) {
|
|
19
19
|
const { x, y, buttons, pressed } = room.input(n); // once per frame per slot
|
|
@@ -56,7 +56,7 @@ A locked/backgrounded phone leaves immediately and rejoins its slot when back (j
|
|
|
56
56
|
|
|
57
57
|
## Host API
|
|
58
58
|
|
|
59
|
-
room.code, room.slots, room.status ('connecting'|'connected'|'reconnecting'|'closed'), room.input(n), room.connected(n), room.qr(n), room.joinUrl(n), room.pad(n, { title?, color?, highlight?: ids (glow and make the phone vibrate briefly (Android only); disabled wins over highlight), disabled?: ids, vibrate?: ms }), room.kick(n), room.send(n, data), room.broadcast(data), room.on('join'|'leave'|'message'|'status'|'error', fn) → unsubscribe, room.close().
|
|
59
|
+
room.code, room.slots, room.status ('connecting'|'connected'|'reconnecting'|'closed'), room.input(n), room.connected(n), room.qr(n?), room.joinUrl(n?) (no n: first free slot), room.pad(n, { title?, color?, highlight?: ids (glow and make the phone vibrate briefly (Android only); disabled wins over highlight), disabled?: ids, vibrate?: ms }), room.kick(n), room.send(n, data), room.broadcast(data), room.on('join'|'leave'|'message'|'status'|'error', fn) → unsubscribe, room.close().
|
|
60
60
|
|
|
61
61
|
## Client API (custom controllers, remote players)
|
|
62
62
|
|
package/package.json
CHANGED
package/public/host.d.ts
CHANGED
|
@@ -90,10 +90,10 @@ export interface Room {
|
|
|
90
90
|
/** Current input of slot n (1-based). Never undefined: neutral when the slot is empty. Clears `pressed`. Throws RangeError for a bad slot. */
|
|
91
91
|
input(n: number): Input;
|
|
92
92
|
connected(n: number): boolean;
|
|
93
|
-
/** URL of an SVG QR code that opens the pad for slot n. Use as <img src>. */
|
|
94
|
-
qr(n
|
|
95
|
-
/** The URL inside the QR. Show it as text under the QR. */
|
|
96
|
-
joinUrl(n
|
|
93
|
+
/** URL of an SVG QR code that opens the pad for slot n. Use as <img src>. Without n, every phone that scans it gets the first free slot. */
|
|
94
|
+
qr(n?: number): string;
|
|
95
|
+
/** The URL inside the QR. Show it as text under the QR. Without n: the first-free-slot link. */
|
|
96
|
+
joinUrl(n?: number): string;
|
|
97
97
|
pad(n: number, patch: PadPatch): void;
|
|
98
98
|
/** Disconnect the phone in slot n; the slot becomes free. */
|
|
99
99
|
kick(n: number): void;
|
package/public/host.js
CHANGED
|
@@ -52,6 +52,11 @@ export function host({ slots = 2, layout, server } = {}) {
|
|
|
52
52
|
return state[n - 1];
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// '/n' or '&slot=n' for a valid slot; no n: the phone gets the first free slot.
|
|
56
|
+
function slotPart(n, prefix) {
|
|
57
|
+
return n === undefined ? '' : (at(n), `${prefix}${n}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
55
60
|
function neutral(s) {
|
|
56
61
|
s.x = 0;
|
|
57
62
|
s.y = 0;
|
|
@@ -167,8 +172,8 @@ export function host({ slots = 2, layout, server } = {}) {
|
|
|
167
172
|
return out;
|
|
168
173
|
},
|
|
169
174
|
connected: (n) => at(n).connected,
|
|
170
|
-
qr: (n) =>
|
|
171
|
-
joinUrl: (n) =>
|
|
175
|
+
qr: (n) => `${base}/joinstick/qr.svg?room=${code}${slotPart(n, '&slot=')}`,
|
|
176
|
+
joinUrl: (n) => `${joinBase}/j/${code}${slotPart(n, '/')}`,
|
|
172
177
|
pad(n, patch) {
|
|
173
178
|
at(n);
|
|
174
179
|
const { vibrate, ...keep } = patch;
|
package/public/pad.css
CHANGED
|
@@ -61,7 +61,9 @@ body { position: fixed; inset: 0; }
|
|
|
61
61
|
align-items: center;
|
|
62
62
|
gap: 10px;
|
|
63
63
|
padding: 8px 16px 8px 24px;
|
|
64
|
-
border-bottom:
|
|
64
|
+
border-bottom: 4px solid var(--accent); /* the player's color, visible from across the room */
|
|
65
|
+
background: var(--panel);
|
|
66
|
+
background: color-mix(in srgb, var(--accent) 22%, var(--bg));
|
|
65
67
|
}
|
|
66
68
|
.side { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
67
69
|
.side.end { justify-content: flex-end; }
|
package/public/pad.html
CHANGED
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
<label>Room code
|
|
49
49
|
<input name="room" maxlength="4" autocomplete="off" autocapitalize="characters" autocorrect="off" spellcheck="false" required pattern="[A-Za-z]{4}" placeholder="KQZX">
|
|
50
50
|
</label>
|
|
51
|
-
<label>Player number
|
|
52
|
-
<input name="slot" type="number" inputmode="numeric" min="1" max="8"
|
|
51
|
+
<label>Player number (optional)
|
|
52
|
+
<input name="slot" type="number" inputmode="numeric" min="1" max="8" placeholder="Any free">
|
|
53
53
|
</label>
|
|
54
54
|
<button class="primary">Join</button>
|
|
55
55
|
</form>
|
package/public/pad.js
CHANGED
|
@@ -30,7 +30,7 @@ function showError(err, room, slot) {
|
|
|
30
30
|
$('pad').hidden = true;
|
|
31
31
|
const free = err.free ?? [];
|
|
32
32
|
const messages = {
|
|
33
|
-
'slot-taken': [`Player ${slot} is taken
|
|
33
|
+
'slot-taken': [slot ? `Player ${slot} is taken` : 'No free spot', free.length ? 'Pick a free spot:' : 'The game is full.', free.map((n) => [`Player ${n}`, go(`/j/${room}/${n}`)])],
|
|
34
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
35
|
'bad-slot': ['No such player', `Room ${room} has no player ${slot}.`, [['Take a free spot', go(`/j/${room}`)]]],
|
|
36
36
|
kicked: ['You were removed', 'The game disconnected this controller.', [['Join again', reload]]],
|
|
@@ -52,7 +52,8 @@ function showForm() {
|
|
|
52
52
|
form.hidden = false;
|
|
53
53
|
form.onsubmit = (e) => {
|
|
54
54
|
e.preventDefault();
|
|
55
|
-
|
|
55
|
+
const slot = form.slot.value; // blank: the first free slot
|
|
56
|
+
location.assign(`/j/${form.room.value.trim().toUpperCase()}${slot ? `/${slot}` : ''}`);
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
|
package/src/cli.js
CHANGED
|
@@ -15,6 +15,10 @@ Options:
|
|
|
15
15
|
--public-url <url> Base URL phones should open, e.g. https://abc.trycloudflare.com
|
|
16
16
|
(default: $PUBLIC_URL, else the address the game page was
|
|
17
17
|
opened with, else the detected LAN address)
|
|
18
|
+
--client-ip-header <name>
|
|
19
|
+
Header your reverse proxy sets to the visitor's address,
|
|
20
|
+
e.g. fly-client-ip or x-real-ip (default: $CLIENT_IP_HEADER).
|
|
21
|
+
Only behind a proxy that sets it: clients can fake it.
|
|
18
22
|
-v, --version Print the version
|
|
19
23
|
-h, --help Show this help
|
|
20
24
|
`;
|
|
@@ -26,6 +30,7 @@ try {
|
|
|
26
30
|
port: { type: 'string' },
|
|
27
31
|
static: { type: 'string' },
|
|
28
32
|
'public-url': { type: 'string' },
|
|
33
|
+
'client-ip-header': { type: 'string' },
|
|
29
34
|
help: { type: 'boolean', short: 'h' },
|
|
30
35
|
version: { type: 'boolean', short: 'v' },
|
|
31
36
|
},
|
|
@@ -46,7 +51,8 @@ if (args.help) {
|
|
|
46
51
|
const port = Number(args.port ?? process.env.PORT ?? 3000);
|
|
47
52
|
const publicUrl = args['public-url'] ?? process.env.PUBLIC_URL;
|
|
48
53
|
const server = http.createServer();
|
|
49
|
-
|
|
54
|
+
const clientIpHeader = args['client-ip-header'] ?? process.env.CLIENT_IP_HEADER;
|
|
55
|
+
attach(server, { static: args.static, publicUrl, clientIpHeader });
|
|
50
56
|
|
|
51
57
|
server.on('error', (err) => {
|
|
52
58
|
console.error(err.code === 'EADDRINUSE' ? `Port ${port} is already in use. Try --port ${port + 1}` : err.message);
|
package/src/server.d.ts
CHANGED
|
@@ -7,6 +7,12 @@ export interface AttachOptions {
|
|
|
7
7
|
publicUrl?: string;
|
|
8
8
|
/** How long a room waits for its game page to come back before closing, in ms. Default 60000. */
|
|
9
9
|
hostGraceMs?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Header a trusted reverse proxy sets to the visitor's address, e.g. 'fly-client-ip' or
|
|
12
|
+
* 'x-real-ip'. The per-address limits use it (first value) instead of the socket address.
|
|
13
|
+
* Only set it behind a proxy that overwrites the header: clients can send it themselves.
|
|
14
|
+
*/
|
|
15
|
+
clientIpHeader?: string;
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
/**
|
package/src/server.js
CHANGED
|
@@ -157,13 +157,14 @@ function fatal(ws, code) {
|
|
|
157
157
|
* Existing 'request' listeners keep working for every other path.
|
|
158
158
|
*
|
|
159
159
|
* @param {import('node:http').Server} server
|
|
160
|
-
* @param {{ static?: string, publicUrl?: string, hostGraceMs?: number }} [opts]
|
|
160
|
+
* @param {{ static?: string, publicUrl?: string, hostGraceMs?: number, clientIpHeader?: string }} [opts]
|
|
161
161
|
* @returns {{ close(): void }}
|
|
162
162
|
*/
|
|
163
163
|
export function attach(server, opts = {}) {
|
|
164
164
|
const publicUrl = opts.publicUrl?.replace(/\/+$/, '');
|
|
165
165
|
const staticDir = opts.static && path.resolve(opts.static);
|
|
166
166
|
const hostGrace = opts.hostGraceMs ?? 60_000;
|
|
167
|
+
const ipHeader = opts.clientIpHeader?.toLowerCase();
|
|
167
168
|
const rooms = new Map();
|
|
168
169
|
const failedJoins = new Map(); // ip -> { count, since }
|
|
169
170
|
// Right after a (re)start, phones of recreated rooms retry their joins
|
|
@@ -172,6 +173,10 @@ export function attach(server, opts = {}) {
|
|
|
172
173
|
let closed = false;
|
|
173
174
|
const wss = new WebSocketServer({ noServer: true, maxPayload: 8 * 1024 });
|
|
174
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
|
+
|
|
175
180
|
function joinBase(req) {
|
|
176
181
|
if (publicUrl) return publicUrl;
|
|
177
182
|
const proto = req.socket.encrypted ? 'https' : 'http';
|
|
@@ -216,7 +221,7 @@ export function attach(server, opts = {}) {
|
|
|
216
221
|
const wanted = typeof m.room === 'string' ? m.room.toUpperCase() : '';
|
|
217
222
|
let room = rooms.get(wanted);
|
|
218
223
|
|
|
219
|
-
const ip =
|
|
224
|
+
const ip = ws.ip;
|
|
220
225
|
let resumed = false;
|
|
221
226
|
if (room && room.hostToken === m.hostToken && room.slots.length === n) {
|
|
222
227
|
// Resume: same room, same pads. Pads get a fresh 'joined' when the
|
|
@@ -234,7 +239,7 @@ export function attach(server, opts = {}) {
|
|
|
234
239
|
}
|
|
235
240
|
} else {
|
|
236
241
|
if (rooms.size >= MAX_ROOMS) return fatal(ws, 'server-full');
|
|
237
|
-
// ponytail: per-IP cap only; behind a tunnel every host shares one IP.
|
|
242
|
+
// ponytail: per-IP cap only; behind a tunnel without clientIpHeader every host shares one IP.
|
|
238
243
|
if ([...rooms.values()].filter((r) => r.ip === ip).length >= MAX_ROOMS_PER_IP) return fatal(ws, 'server-full');
|
|
239
244
|
// Recreate (e.g. after a server restart) keeps the code so pads can rejoin.
|
|
240
245
|
const recreate = !room && CODE.test(wanted) && HOST_TOKEN.test(m.hostToken ?? '');
|
|
@@ -262,8 +267,8 @@ export function attach(server, opts = {}) {
|
|
|
262
267
|
const now = Date.now();
|
|
263
268
|
let recent = failedJoins.get(ws.ip);
|
|
264
269
|
if (!recent || now - recent.since >= FAILED_WINDOW) recent = { count: 0, since: now };
|
|
265
|
-
//
|
|
266
|
-
// locks every remote phone out for a minute
|
|
270
|
+
// Keyed by client address: behind a proxy without clientIpHeader, one guesser
|
|
271
|
+
// locks every remote phone out for a minute.
|
|
267
272
|
if (recent.count >= MAX_FAILED_JOINS_PER_IP) return fatal(ws, 'rate-limited');
|
|
268
273
|
const fail = (code, extra) => {
|
|
269
274
|
send(ws, { t: 'error', code, ...extra });
|
|
@@ -350,7 +355,7 @@ export function attach(server, opts = {}) {
|
|
|
350
355
|
wss.on('connection', (ws, req) => {
|
|
351
356
|
ws.seen = Date.now();
|
|
352
357
|
ws.failedJoins = 0;
|
|
353
|
-
ws.ip = req
|
|
358
|
+
ws.ip = clientIp(req);
|
|
354
359
|
ws.on('message', (raw, isBinary) => {
|
|
355
360
|
ws.seen = Date.now();
|
|
356
361
|
let m;
|
|
@@ -411,10 +416,10 @@ export function attach(server, opts = {}) {
|
|
|
411
416
|
return true;
|
|
412
417
|
}
|
|
413
418
|
|
|
414
|
-
async function serveStatic(req, res,
|
|
419
|
+
async function serveStatic(req, res, url) {
|
|
415
420
|
let rel;
|
|
416
421
|
try {
|
|
417
|
-
rel = decodeURIComponent(pathname);
|
|
422
|
+
rel = decodeURIComponent(url.pathname);
|
|
418
423
|
} catch {
|
|
419
424
|
return false;
|
|
420
425
|
}
|
|
@@ -422,6 +427,12 @@ export function attach(server, opts = {}) {
|
|
|
422
427
|
let file = path.resolve(staticDir, '.' + rel);
|
|
423
428
|
if (file !== staticDir && !file.startsWith(staticDir + path.sep)) return false;
|
|
424
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
|
+
}
|
|
425
436
|
return serveFile(req, res, file);
|
|
426
437
|
}
|
|
427
438
|
|
|
@@ -442,13 +453,14 @@ export function attach(server, opts = {}) {
|
|
|
442
453
|
if (p === '/llms.txt') return serveFile(req, res, path.join(ROOT, 'llms.txt'), cors);
|
|
443
454
|
if (p === '/joinstick/qr.svg') {
|
|
444
455
|
const room = rooms.get((url.searchParams.get('room') ?? '').toUpperCase());
|
|
445
|
-
const slot =
|
|
446
|
-
|
|
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;
|
|
447
459
|
res.writeHead(200, { 'content-type': 'image/svg+xml', 'cache-control': 'no-store', ...cors });
|
|
448
|
-
res.end(renderSVG(`${room.joinBase}/j/${room.code}
|
|
460
|
+
res.end(renderSVG(`${room.joinBase}/j/${room.code}${slot === null ? '' : `/${n}`}`, { border: 2 }));
|
|
449
461
|
return true;
|
|
450
462
|
}
|
|
451
|
-
if (staticDir) return serveStatic(req, res,
|
|
463
|
+
if (staticDir) return serveStatic(req, res, url);
|
|
452
464
|
return false;
|
|
453
465
|
}
|
|
454
466
|
|