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 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 one QR code per player slot; a player scans it and the phone
5
- becomes a controller. Read this file before integrating; it is the whole
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
- for (let n = 1; n <= room.slots; n++) showQr(n, room.qr(n), room.joinUrl(n)); // <img src=qr> + the URL as text
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) // URL of an SVG QR code for <img src>
189
- room.joinUrl(n) // the URL inside the QR: show it as text too
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}`. The QR for a slot is
104
- `/joinstick/qr.svg?room=KQZX&slot=1` on the Joinstick server.
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
  [![node >= 22](https://img.shields.io/badge/node-%3E%3D22-339933)](package.json)
11
11
  [![CI](https://github.com/raprav/joinstick/actions/workflows/ci.yml/badge.svg)](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: the example game on a laptop, showing a room code, two moving squares and a QR code per player. Right: a phone held sideways showing the Joinstick pad with a d-pad and two buttons." width="800"></p>
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(1); // player 1 scans this
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. Each slot has a QR
77
- code that opens `/j/KQZX/<slot>` on a phone. The phone sends its controller
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. With Caddy (which passes WebSockets through by default):
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 player slot; scanning it opens a controller page on the phone. Self-hosted Node server (`npx joinstick`), plain ESM browser SDKs, JSON over WebSocket.
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(n)}> and the text room.joinUrl(n) for n = 1..room.slots
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "joinstick",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Use phones as gamepads for HTML5 browser games: scan a QR, play. Self-hosted and open source, no accounts, no app install.",
5
5
  "license": "MIT",
6
6
  "author": {
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: number): string;
95
- /** The URL inside the QR. Show it as text under the QR. */
96
- joinUrl(n: number): string;
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) => at(n) && `${base}/joinstick/qr.svg?room=${code}&slot=${n}`,
171
- joinUrl: (n) => at(n) && `${joinBase}/j/${code}/${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: 1px solid var(--line);
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" value="1" required>
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`, free.length ? 'Pick a free spot:' : 'The game is full.', free.map((n) => [`Player ${n}`, go(`/j/${room}/${n}`)])],
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
- location.assign(`/j/${form.room.value.trim().toUpperCase()}/${form.slot.value}`);
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
- attach(server, { static: args.static, publicUrl });
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 = req.socket.remoteAddress;
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
- // ponytail: keyed by socket address, so behind a proxy or tunnel one guesser
266
- // locks every remote phone out for a minute; key on a trusted header if that matters.
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.socket.remoteAddress;
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, pathname) {
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 = Number(url.searchParams.get('slot'));
446
- if (!room || !Number.isInteger(slot) || slot < 1 || slot > room.slots.length) return false;
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}/${slot}`, { border: 2 }));
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, p);
463
+ if (staticDir) return serveStatic(req, res, url);
452
464
  return false;
453
465
  }
454
466