tickcli-js 0.0.0-stage → 0.1.1

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/src/node.ts ADDED
@@ -0,0 +1,245 @@
1
+ // tickcli on Node: sockets (the ws package) to the hub, plus the small security pieces every host app needs. No web framework assumed:
2
+ // use attach() with a plain ws server, or socketEvents() from any framework's WebSocket upgrade helper.
3
+ //
4
+ // Who someone is never comes from the game page. Two ways in, both decided by the host app:
5
+ // tickets - the app signs a short, one-time ticket for a signed-in person; the page's trusted wrapper sends it as the socket's FIRST message
6
+ // (not in the address: proxies write addresses to their logs). Until it arrives the socket counts as unverified (capped, 5 s).
7
+ // passes - for anonymous visitors: the app sets a pass cookie (HttpOnly, SameSite=Strict, scoped to one path) and checks it at the upgrade.
8
+ // Plus: the Origin header must be present and name the site itself (sameSite()).
9
+ import crypto from "node:crypto";
10
+ import type { WebSocket } from "ws";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { CLOSE, type Conn, type RoomHub, type SavePort, type Session, type Who } from "./hub.ts";
14
+
15
+ /** A game's version: a short hash of the page. Players on another version are told to reload. */
16
+ /** A peer that is this machine or a private network: the only kind whose X-Forwarded-For is believed (your proxy, not the internet). */
17
+ const PRIVATE_PEER = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|f[cd][0-9a-f]{2}:|fe80:)/i;
18
+
19
+ /**
20
+ * The client's address for the per-address limits. Behind a proxy (Caddy, nginx) every socket comes from the proxy, so all players would
21
+ * share one address and the limits (20 sockets, 30 joins a minute) would cap the whole server: with `trustProxy`, a request from a private
22
+ * or loopback peer is taken at the last X-Forwarded-For entry (the address the proxy saw). From any other peer the header is ignored, so a
23
+ * player cannot pick their own address.
24
+ */
25
+ export function clientAddress(req: { socket: { remoteAddress?: string }; headers: Record<string, string | string[] | undefined> }, trustProxy = false): string {
26
+ const peer = String(req.socket.remoteAddress || "?").replace(/^::ffff:/i, "");
27
+ if (!trustProxy || !PRIVATE_PEER.test(peer)) return peer;
28
+ const raw = req.headers["x-forwarded-for"];
29
+ const list = (Array.isArray(raw) ? raw.join(",") : String(raw || "")).split(",").map((x) => x.trim()).filter(Boolean);
30
+ const last = (list[list.length - 1] || "").replace(/^::ffff:/i, "");
31
+ return /^[0-9a-f.:]{2,45}$/i.test(last) ? last : peer;
32
+ }
33
+
34
+ export const gameHash = (bytes: Uint8Array | string) => crypto.createHash("sha256").update(bytes).digest("base64url").slice(0, 16);
35
+
36
+ /** Hosts from a comma list of origins or host names ("https://a.example, b.example:8443"). */
37
+ export const parseOrigins = (v: string | undefined) => String(v || "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean).map((s) => { try { return new URL(s.includes("://") ? s : `https://${s}`).host; } catch { return s; } });
38
+
39
+ /**
40
+ * Same-site check for a socket upgrade (and any route that hands out a pass): Origin required, its host must be the host the request came to
41
+ * (the scheme is not compared: behind a TLS proxy the app sees http), and with an allow list it must be on it. Sec-Fetch-Site, when sent, must say
42
+ * same-origin. `header` reads one request header by lower-case name.
43
+ */
44
+ export function sameSite(header: (name: string) => string | undefined, allow: string[] = []): boolean {
45
+ const origin = header("origin");
46
+ if (!origin) return false;
47
+ const site = header("sec-fetch-site");
48
+ if (site && site !== "same-origin") return false;
49
+ let host = "";
50
+ try { host = new URL(origin).host.toLowerCase(); } catch { return false; }
51
+ const own = (header("host") || "").toLowerCase();
52
+ if (!host || host !== own) return false;
53
+ return !allow.length || allow.includes(host);
54
+ }
55
+
56
+ // The purpose labels below ("tickcli guest pass", "tickcli ticket") are part of every pass and ticket's signature: changing them signs out
57
+ // every anonymous player and orphans the games saved under their seat. Never rename them.
58
+ const hmacKey = (secret: string | Buffer, purpose: string) => crypto.createHmac("sha256", Buffer.from(secret)).update(purpose).digest();
59
+
60
+ /** Anonymous passes: "<random>.<mac>", the mac binds the pass to one scope (a public link, a lobby page). Nothing is stored. */
61
+ export class GuestPasses {
62
+ private key: Buffer;
63
+ constructor(secret: string | Buffer) { this.key = hmacKey(secret, "tickcli guest pass"); }
64
+ private mac(scope: string, r: string) { return crypto.createHmac("sha256", this.key).update(`${scope}|${r}`).digest("base64url").slice(0, 22); }
65
+ issue(scope: string): string { const r = crypto.randomBytes(16).toString("base64url"); return `${r}.${this.mac(scope, r)}`; }
66
+ /** The pass id (never the pass itself) when the value is a pass for this scope, else null. */
67
+ check(scope: string, value: string | undefined): string | null {
68
+ const m = /^([A-Za-z0-9_-]{22})\.([A-Za-z0-9_-]{22})$/.exec(String(value || ""));
69
+ if (!m) return null;
70
+ const want = Buffer.from(this.mac(scope, m[1])), got = Buffer.from(m[2]);
71
+ return want.length === got.length && crypto.timingSafeEqual(want, got) ? m[1] : null;
72
+ }
73
+ /** "Guest 4821": a stable default name from a pass id. */
74
+ static defaultName(id: string) { return `Guest ${1000 + (crypto.createHash("sha256").update(id).digest().readUInt32BE(0) % 9000)}`; }
75
+ /** The Set-Cookie value for a pass (HttpOnly, SameSite=Strict, limited to `path`). */
76
+ static cookie(name: string, pass: string, path: string, secure: boolean, maxAgeSec = 400 * 24 * 3600) {
77
+ return `${name}=${pass}; Max-Age=${maxAgeSec}; Path=${path}; HttpOnly; SameSite=Strict${secure ? "; Secure" : ""}`;
78
+ }
79
+ }
80
+
81
+ /** One-time tickets: HMAC-signed claims with an expiry and an id that can be spent once (kept in memory until it expires). */
82
+ export class Tickets<C extends object = Record<string, unknown>> {
83
+ private key: Buffer;
84
+ private spent = new Map<string, number>();
85
+ readonly ttlSec: number;
86
+ constructor(secret: string | Buffer, ttlSec = 30) { this.key = hmacKey(secret, "tickcli ticket"); this.ttlSec = ttlSec; }
87
+ issue(claims: C): string {
88
+ const body = Buffer.from(JSON.stringify({ c: claims, exp: Date.now() + this.ttlSec * 1000, j: crypto.randomBytes(12).toString("base64url") })).toString("base64url");
89
+ return `${body}.${crypto.createHmac("sha256", this.key).update(body).digest("base64url")}`;
90
+ }
91
+ /** The claims of a valid, unexpired, unused ticket; taking it spends it. */
92
+ take(ticket: unknown): C | null {
93
+ if (typeof ticket !== "string" || ticket.length > 4000) return null;
94
+ const [body, mac] = ticket.split(".");
95
+ if (!body || !mac) return null;
96
+ const want = crypto.createHmac("sha256", this.key).update(body).digest(), got = Buffer.from(mac, "base64url");
97
+ if (got.length !== want.length || !crypto.timingSafeEqual(got, want)) return null;
98
+ let t: { c: C; exp: number; j: string };
99
+ try { t = JSON.parse(Buffer.from(body, "base64url").toString()); } catch { return null; }
100
+ const now = Date.now();
101
+ if (!t || typeof t.exp !== "number" || t.exp < now || typeof t.j !== "string") return null;
102
+ if (this.spent.size > 10_000) for (const [k, exp] of this.spent) if (exp < now) this.spent.delete(k);
103
+ if (this.spent.has(t.j)) return null;
104
+ this.spent.set(t.j, t.exp + 1000);
105
+ return t.c;
106
+ }
107
+ }
108
+
109
+ /** The hub's view of one ws socket. */
110
+ export const connOf = (ws: WebSocket): Conn => ({
111
+ send: (d: string) => { if (ws.readyState === 1) ws.send(d); },
112
+ close: (code: number, reason: string) => { try { ws.close(code, reason.slice(0, 120)); } catch { ws.terminate(); } },
113
+ buffered: () => ws.bufferedAmount,
114
+ });
115
+
116
+ /** Pings every socket every 30 s; one that did not answer the last ping is cut (a phone that went away without closing). */
117
+ export class Heartbeat {
118
+ private live = new Map<WebSocket, boolean>();
119
+ private timer: ReturnType<typeof setInterval>;
120
+ constructor(ms = 30_000) {
121
+ this.timer = setInterval(() => {
122
+ for (const [ws, ok] of this.live) {
123
+ if (!ok) { ws.terminate(); this.live.delete(ws); continue; }
124
+ this.live.set(ws, false);
125
+ try { ws.ping(); } catch { /* closing */ }
126
+ }
127
+ }, ms);
128
+ this.timer.unref?.();
129
+ }
130
+ add(ws: WebSocket) { this.live.set(ws, true); ws.on("pong", () => this.live.set(ws, true)); }
131
+ remove(ws: WebSocket) { this.live.delete(ws); }
132
+ stop() { clearInterval(this.timer); }
133
+ }
134
+
135
+ /** Who a socket is and what it may join: a namespace (team, link, page), a game version and the person. */
136
+ export interface Identity {
137
+ scope: string; game: string; who: Who;
138
+ /** a stable key for the game's saves (a file path, a page id); it must not change when the page is edited. Without it nothing is saved. */
139
+ slot?: string;
140
+ }
141
+
142
+ export interface SocketOptions {
143
+ hub: RoomHub;
144
+ heartbeat?: Heartbeat;
145
+ /** client address, for the per-address caps (after your trusted-proxy rule) */
146
+ address: string;
147
+ /** known at the upgrade (a pass cookie checked by the caller) */
148
+ identity?: Identity;
149
+ /** otherwise: the first message must be {v:1, t:"auth", ticket}; this turns the ticket into an identity, or null to refuse (4001) */
150
+ authenticate?: (ticket: unknown) => Promise<Identity | null> | Identity | null;
151
+ }
152
+
153
+ /**
154
+ * The life of one socket, as three plain callbacks for any WebSocket helper (Hono's upgradeWebSocket, uWS, ...): open(ws), message(text), close().
155
+ * Messages that arrive while a ticket is being checked wait (at most 8).
156
+ */
157
+ export function socketEvents(o: SocketOptions) {
158
+ let session: Session | null = null;
159
+ let raw: WebSocket | null = null;
160
+ let waiting: string[] | null = null;
161
+ return {
162
+ open(ws: WebSocket) {
163
+ raw = ws;
164
+ o.heartbeat?.add(ws);
165
+ const r = o.hub.connect(connOf(ws), o.address);
166
+ if ("refused" in r) { ws.close(r.refused, r.reason); return; }
167
+ session = r;
168
+ if (o.identity) session.identify(o.identity.scope, o.identity.game, o.identity.who, o.identity.slot);
169
+ },
170
+ message(text: unknown) {
171
+ const s = session;
172
+ if (!s || s.dead) return;
173
+ const t = typeof text === "string" ? text : "";
174
+ if (s.who) return s.receive(t);
175
+ if (waiting) { if (waiting.length < 8) waiting.push(t); else s.end(CLOSE.badMessage, "too many messages before sign-in"); return; }
176
+ waiting = [];
177
+ let ticket: unknown;
178
+ try { const m = JSON.parse(t); ticket = m && m.v === 1 && m.t === "auth" ? m.ticket : undefined; } catch { ticket = undefined; }
179
+ void Promise.resolve().then(() => (o.authenticate && ticket !== undefined ? o.authenticate(ticket) : null)).catch(() => null).then((id) => {
180
+ if (s.dead) return;
181
+ if (!id) { s.end(CLOSE.notAllowed, "not allowed"); return; }
182
+ s.identify(id.scope, id.game, id.who, id.slot);
183
+ const q = waiting || [];
184
+ waiting = null;
185
+ for (const m of q) s.receive(m);
186
+ });
187
+ },
188
+ close() {
189
+ if (raw) o.heartbeat?.remove(raw);
190
+ session?.closed();
191
+ },
192
+ };
193
+ }
194
+
195
+ /** For a plain ws server: wire one accepted socket to the hub. */
196
+ export function attach(ws: WebSocket, o: SocketOptions) {
197
+ const ev = socketEvents(o);
198
+ ev.open(ws);
199
+ ws.on("message", (data, isBinary) => ev.message(isBinary ? "" : String(data)));
200
+ ws.on("close", () => ev.close());
201
+ ws.on("error", () => { /* close follows */ });
202
+ }
203
+
204
+ /** Options for a ws WebSocketServer that fit the hub: no own size default of 100 MiB, no compression, no client list (the hub tracks sockets). */
205
+ export const WS_OPTIONS = { noServer: true, maxPayload: 16 * 1024, perMessageDeflate: false, clientTracking: false } as const;
206
+
207
+ /** Saves in memory (tests, a demo that may forget on restart). */
208
+ export function memorySaves(): SavePort & { docs: Map<string, { at: number; data: unknown; v?: number }> } {
209
+ const docs = new Map<string, { at: number; data: unknown; v?: number }>();
210
+ const key = (scope: string, slot: string, who: string | null) => JSON.stringify([scope, slot, who]);
211
+ return {
212
+ docs,
213
+ async load(scope, slot, who) { const d = docs.get(key(scope, slot, who)); return d ? structuredClone(d) : null; },
214
+ async store(scope, slot, who, data, meta) { if (data === null) docs.delete(key(scope, slot, who)); else docs.set(key(scope, slot, who), { at: Date.now(), data: structuredClone(data), ...(meta?.v ? { v: meta.v } : {}) }); },
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Saves as small JSON files in one directory (a standalone server that must keep them across restarts). File names are a hash of the key, so nothing a
220
+ * player or page controls reaches the path. A file written half way never replaces a good one (write to a temp name, then rename).
221
+ */
222
+ export function fileSaves(dir: string): SavePort {
223
+ fs.mkdirSync(dir, { recursive: true });
224
+ const file = (scope: string, slot: string, who: string | null) => path.join(dir, crypto.createHash("sha256").update(JSON.stringify([scope, slot, who])).digest("hex").slice(0, 40) + ".json");
225
+ return {
226
+ async load(scope, slot, who) {
227
+ const f = file(scope, slot, who);
228
+ let text: string;
229
+ try { text = await fs.promises.readFile(f, "utf8"); } catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return null; throw e; }
230
+ try { const d = JSON.parse(text); if (d && typeof d.at === "number") return { at: d.at, data: d.data, ...(typeof d.v === "number" ? { v: d.v } : {}) }; } catch { /* below */ }
231
+ // there but not a save (a damaged file): kept aside, never deleted, and not read as an empty game
232
+ await fs.promises.rename(f, `${f}.bad-${Date.now()}-${crypto.randomBytes(2).toString("hex")}`).catch(() => {});
233
+ return null;
234
+ },
235
+ async store(scope, slot, who, data, meta) {
236
+ const f = file(scope, slot, who);
237
+ if (data === null) { await fs.promises.rm(f, { force: true }); return; }
238
+ const tmp = `${f}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
239
+ // flushed to disk before the rename, so a save reported written survives a power cut on any file system
240
+ const fh = await fs.promises.open(tmp, "w", 0o600);
241
+ try { await fh.writeFile(JSON.stringify({ at: Date.now(), ...(meta?.v ? { v: meta.v } : {}), data })); await fh.sync(); } finally { await fh.close(); }
242
+ await fs.promises.rename(tmp, f);
243
+ },
244
+ };
245
+ }
package/src/remote.ts ADDED
@@ -0,0 +1,236 @@
1
+ // tickcli-js/remote: games hosted anywhere (the author's own site, a portal, GitHub Pages) play in rooms on your tickcli server.
2
+ // The author pastes one tag; nothing else runs on their side:
3
+ //
4
+ // <script src="https://rooms.example/c/dep_ab12.js" data-game="tag"></script> <!-- relay + room.js + game.js, set up for deployment dep_ab12 -->
5
+ // <script> Game({ ... }) </script>
6
+ //
7
+ // A deployment is a public id with a list of the sites allowed to use it. The server checks the page's Origin at the socket upgrade (browsers
8
+ // send it and page scripts cannot change it), so another site cannot run its rooms on that deployment. One kind of deployment: https sites,
9
+ // plus http only for this machine when listed ("http://localhost:*"), so an author can test the same page from their laptop.
10
+ // Guests: the page keeps a random 128-bit key in its own storage and sends it as the socket's first message (no cookie, so it works from any
11
+ // site); the same key is the same seat. Each deployment is its own namespace (rooms and saves never mix) with optional caps on rooms,
12
+ // connected players and data out.
13
+ //
14
+ // What this does not do: a script outside a browser can send any Origin it likes. The caps, the hub's per-address limits and revoke() are
15
+ // the guard against that, not the origin list.
16
+ import fs from "node:fs";
17
+ import http from "node:http";
18
+ import crypto from "node:crypto";
19
+ import type { Duplex } from "node:stream";
20
+ import { fileURLToPath } from "node:url";
21
+ import { WebSocketServer, type WebSocket } from "ws";
22
+ import { CLOSE, RoomHub, type Limits, type SavePort } from "./hub.ts";
23
+ import { GuestPasses, Heartbeat, WS_OPTIONS, attach, clientAddress, fileSaves, gameHash } from "./node.ts";
24
+
25
+ export interface Deployment {
26
+ /** public id, as in the page's script tag: letters, digits, "_" and "-", 3 to 64 */
27
+ id: string;
28
+ /** sites allowed to use it: "https://mygame.example", "https://mygame.example:8443", one level of wildcard subdomain
29
+ * ("https://*.mygame.example") or any port (":*"). http works only for localhost, 127.0.0.1 and [::1] ("http://localhost:*" to test from a
30
+ * laptop); an http entry for any other host never matches. */
31
+ origins: string[];
32
+ /** turned off: new sockets are refused (call revoke() to close its open rooms too) */
33
+ revoked?: boolean;
34
+ caps?: {
35
+ /** open rooms at once (default: the hub's maxRoomsPerScope) */
36
+ rooms?: number;
37
+ /** connected sockets at once */
38
+ players?: number;
39
+ /** data going out to its players, KB a second (over the last second); over it, new sockets wait (busy) and open rooms carry on */
40
+ kbPerSec?: number;
41
+ };
42
+ }
43
+
44
+ export interface RemoteOptions {
45
+ hub: RoomHub;
46
+ /** look a deployment up by id (your table, a JSON file); null or undefined = no such deployment */
47
+ deployment: (id: string) => Deployment | null | undefined | Promise<Deployment | null | undefined>;
48
+ heartbeat?: Heartbeat;
49
+ /** the client address after your trusted-proxy rule (default: the socket's address) */
50
+ address?: (req: http.IncomingMessage) => string;
51
+ /** socket path (default "/room") */
52
+ path?: string;
53
+ }
54
+
55
+ export interface Remote {
56
+ /** For server.on("upgrade"): true when the request was for the room socket (handled), false to leave it to you. */
57
+ upgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer): boolean;
58
+ /** For your request handler: serves /c/<id>.js and /client/<name>.js; true when it answered. */
59
+ request(req: http.IncomingMessage, res: http.ServerResponse): boolean;
60
+ /** Turn a deployment off now: its open rooms close (saves are written first) and its cached settings are dropped. */
61
+ revoke(id: string): void;
62
+ /** The hub namespace of a deployment (for hub.scopeStats, hub.closeScope). */
63
+ scope(id: string): string;
64
+ /** The rooms cap the hub should use for a namespace (pass to new RoomHub({ roomsPerScope })). */
65
+ roomsPerScope(scope: string): number | undefined;
66
+ /** connected sockets per deployment id */
67
+ sockets(id: string): number;
68
+ }
69
+
70
+ const ID_RE = /^[A-Za-z0-9_-]{3,64}$/;
71
+ const GAME_RE = /^[A-Za-z0-9_-]{1,60}$/;
72
+ const VER_RE = /^[A-Za-z0-9._-]{1,40}$/;
73
+ const KEY_RE = /^[A-Za-z0-9_-]{22,64}$/;
74
+ const LOCAL = new Set(["localhost", "127.0.0.1", "[::1]"]);
75
+ const CLIENT = fileURLToPath(new URL("../client/", import.meta.url));
76
+
77
+ /** Is this page origin allowed for the deployment? An entry matches by scheme, host (exact, or "*." for one subdomain level) and port (exact,
78
+ * or ":*" for any). https sites only, except http on this machine (localhost, 127.0.0.1, [::1]) when listed. Never "null" or a file page. */
79
+ export function originAllowed(dep: Pick<Deployment, "origins">, origin: string | undefined): boolean {
80
+ if (!origin || origin === "null") return false;
81
+ let u: URL;
82
+ try { u = new URL(origin); } catch { return false; }
83
+ if (u.protocol !== "https:" && u.protocol !== "http:") return false;
84
+ const host = u.hostname.toLowerCase();
85
+ if (u.protocol === "http:" && !LOCAL.has(host)) return false;
86
+ for (const raw of dep.origins) {
87
+ const m = /^(https?):\/\/(\*\.)?([a-z0-9.-]+|\[[0-9a-f:.]+\])(?::(\d{1,5}|\*))?\/?$/i.exec(raw.trim());
88
+ if (!m || `${m[1].toLowerCase()}:` !== u.protocol) continue;
89
+ const want = m[3].toLowerCase();
90
+ if (m[2] ? !(host.endsWith(`.${want}`) && host.length > want.length + 1) : host !== want) continue;
91
+ const port = m[4] === undefined ? "" : m[4] === "*" ? "*" : String(Number(m[4]) === (u.protocol === "https:" ? 443 : 80) ? "" : Number(m[4]));
92
+ if (port === "*" || port === u.port) return true;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ /** The one-tag boot script for a deployment: relay.js, then the relay set up for this deployment, then room.js and game.js. */
98
+ export function bootScript(id: string, sources: { relay: string; room: string; game: string }): string {
99
+ const boot = `;(function () {
100
+ var s = document.currentScript, d = (s && s.dataset) || {};
101
+ var base = new URL(s && s.src ? s.src : location.href);
102
+ var game = String(d.game || (location.pathname.split("/").pop() || "").replace(/\\.html?$/i, "") || "index").replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 60) || "index";
103
+ var ver = String(d.version || "1").replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 40) || "1";
104
+ var url = new URL("/room", base); url.protocol = base.protocol === "https:" ? "wss:" : "ws:";
105
+ url.searchParams.set("d", ${JSON.stringify(id)}); url.searchParams.set("game", game); url.searchParams.set("v", ver);
106
+ var mem = "";
107
+ function key() {
108
+ var k = "";
109
+ try { k = localStorage.getItem("tickcli.guest") || ""; } catch (e) { k = mem; }
110
+ if (!/^[A-Za-z0-9_-]{22,64}$/.test(k)) {
111
+ var b = new Uint8Array(16); crypto.getRandomValues(b);
112
+ k = btoa(String.fromCharCode.apply(null, Array.prototype.slice.call(b))).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
113
+ try { localStorage.setItem("tickcli.guest", k); } catch (e) { mem = k; }
114
+ }
115
+ return k;
116
+ }
117
+ tickcliRelay({
118
+ frame: function () { return { contentWindow: window }; }, guest: true,
119
+ name: function () { try { return localStorage.getItem("tickcli.name") || ""; } catch (e) { return ""; } },
120
+ onName: function (n) { try { localStorage.setItem("tickcli.name", n); } catch (e) { /* private mode */ } },
121
+ connect: function () { return Promise.resolve({ url: url.href, first: { v: 1, t: "auth", ticket: key() } }); },
122
+ });
123
+ })();
124
+ `;
125
+ return `/* tickcli remote boot, deployment ${id}. MIT. */\n${sources.relay}\n${boot}\n${sources.room}\n;\n${sources.game}\n`;
126
+ }
127
+
128
+ export function createRemote(o: RemoteOptions): Remote {
129
+ const path = o.path ?? "/room";
130
+ const wss = new WebSocketServer({ ...WS_OPTIONS });
131
+ const cache = new Map<string, Deployment>();
132
+ const live = new Map<string, number>();
133
+ const src = (n: string) => fs.readFileSync(`${CLIENT}${n}.js`, "utf8");
134
+ const sources = { relay: src("relay"), room: src("room"), game: src("game") };
135
+ const files: Record<string, string> = { relay: sources.relay, room: sources.room, game: sources.game, game3d: src("game3d") };
136
+ const scope = (id: string) => `dep:${id}`;
137
+ const lookup = async (id: string) => {
138
+ if (!ID_RE.test(id)) return null;
139
+ let dep: Deployment | null | undefined = null;
140
+ try { dep = await o.deployment(id); } catch { dep = null; }
141
+ if (dep && dep.id === id) cache.set(id, dep); else cache.delete(id);
142
+ return dep && dep.id === id ? dep : null;
143
+ };
144
+ // refused sockets are still accepted, then closed with a code the page shows (4001 is final: the page stops trying; 1013 busy: it retries)
145
+ const closeWith = (req: http.IncomingMessage, socket: Duplex, head: Buffer, code: number, reason: string) =>
146
+ wss.handleUpgrade(req, socket, head, (ws: WebSocket) => ws.close(code, reason));
147
+
148
+ return {
149
+ scope,
150
+ sockets: (id) => live.get(id) || 0,
151
+ roomsPerScope: (sc) => (sc.startsWith("dep:") ? cache.get(sc.slice(4))?.caps?.rooms : undefined),
152
+ revoke(id) { cache.delete(id); o.hub.closeScope(scope(id), "this game was turned off by its owner"); },
153
+
154
+ upgrade(req, socket, head) {
155
+ const url = new URL(req.url || "/", "http://localhost");
156
+ if (url.pathname !== path) return false;
157
+ const origin = req.headers.origin;
158
+ if (!origin) { socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); return true; }
159
+ const id = url.searchParams.get("d") || "", game = url.searchParams.get("game") || "", ver = url.searchParams.get("v") || "1";
160
+ if (!GAME_RE.test(game) || !VER_RE.test(ver)) { closeWith(req, socket, head, CLOSE.badMessage, "bad game name or version"); return true; }
161
+ socket.on("error", () => { /* closed early */ });
162
+ void lookup(id).then((dep) => {
163
+ if (socket.destroyed) return;
164
+ if (!dep || dep.revoked) return closeWith(req, socket, head, CLOSE.notAllowed, "this game's deployment is unknown or turned off");
165
+ if (!originAllowed(dep, origin)) return closeWith(req, socket, head, CLOSE.notAllowed, `this site is not allowed for deployment ${id}`);
166
+ const caps = dep.caps || {}, sc = scope(id), n = live.get(id) || 0;
167
+ if (caps.players !== undefined && n >= caps.players) return closeWith(req, socket, head, CLOSE.busy, "this game is full right now, trying again");
168
+ if (caps.kbPerSec !== undefined && o.hub.scopeStats(sc).bytesOut / 1024 > caps.kbPerSec) return closeWith(req, socket, head, CLOSE.busy, "this game is busy right now, trying again");
169
+ wss.handleUpgrade(req, socket, head, (ws: WebSocket) => {
170
+ live.set(id, (live.get(id) || 0) + 1);
171
+ ws.on("close", () => { const left = (live.get(id) || 1) - 1; if (left > 0) live.set(id, left); else live.delete(id); });
172
+ attach(ws, {
173
+ hub: o.hub, heartbeat: o.heartbeat, address: o.address ? o.address(req) : req.socket.remoteAddress || "?",
174
+ // the guest key is the page's own random 128-bit value: unguessable, so nobody can take another player's seat; it names a seat in
175
+ // this deployment only (the hub shows others a short HMAC of it, never the key)
176
+ authenticate: (ticket) => typeof ticket === "string" && KEY_RE.test(ticket)
177
+ ? { scope: sc, game: gameHash(`${game}|${ver}`), slot: game, who: { seat: `g:${ticket}`, name: GuestPasses.defaultName(ticket), guest: true } }
178
+ : null,
179
+ });
180
+ });
181
+ });
182
+ return true;
183
+ },
184
+
185
+ request(req, res) {
186
+ const url = new URL(req.url || "/", "http://localhost");
187
+ const js = (code: number, body: string, cache = "no-store") => {
188
+ res.writeHead(code, { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": cache, "X-Content-Type-Options": "nosniff", "Cross-Origin-Resource-Policy": "cross-origin", "Access-Control-Allow-Origin": "*" });
189
+ res.end(req.method === "HEAD" ? undefined : body);
190
+ };
191
+ if (req.method !== "GET" && req.method !== "HEAD") return false;
192
+ const c = /^\/client\/(relay|room|game|game3d)\.js$/.exec(url.pathname);
193
+ if (c) { js(200, files[c[1]], "public, max-age=300"); return true; }
194
+ const b = /^\/c\/([^/]+)\.js$/.exec(url.pathname);
195
+ if (!b) return false;
196
+ void lookup(b[1]).then((dep) => {
197
+ if (!dep || dep.revoked) return js(404, `console.error(${JSON.stringify(`tickcli: no deployment ${b[1].slice(0, 64)} (unknown or turned off)`)});`);
198
+ js(200, bootScript(dep.id, sources), "public, max-age=300");
199
+ });
200
+ return true;
201
+ },
202
+ };
203
+ }
204
+
205
+ export interface RemoteServerOptions {
206
+ deployment: RemoteOptions["deployment"];
207
+ /** signs public player ids; keep it stable so ids stay the same across restarts (default: random per process) */
208
+ secret?: string;
209
+ /** a folder for saved profiles and worlds, or your own SavePort */
210
+ saves?: string | SavePort;
211
+ limits?: Partial<Limits>;
212
+ address?: RemoteOptions["address"];
213
+ /** behind a proxy on a private network: players' addresses from X-Forwarded-For (see clientAddress in tickcli-js/node) */
214
+ trustProxy?: boolean;
215
+ }
216
+
217
+ /** A complete remote room server: node:http + the hub + createRemote. Add your own routes on `server` before listen(). */
218
+ export function createRemoteServer(o: RemoteServerOptions) {
219
+ let remote: Remote | null = null;
220
+ const saves = typeof o.saves === "string" ? fileSaves(o.saves) : o.saves;
221
+ const hub = new RoomHub({ secret: o.secret ?? crypto.randomBytes(32).toString("base64url"), limits: o.limits, saves, roomsPerScope: (sc) => remote?.roomsPerScope(sc) });
222
+ hub.start();
223
+ const heartbeat = new Heartbeat();
224
+ remote = createRemote({ hub, deployment: o.deployment, heartbeat, address: o.address ?? (o.trustProxy ? (req) => clientAddress(req, true) : undefined) });
225
+ const server = http.createServer((req, res) => {
226
+ if (remote!.request(req, res)) return;
227
+ res.writeHead(404, { "Content-Type": "text/plain" }); res.end("not found");
228
+ });
229
+ server.on("upgrade", (req, socket, head) => { if (!remote!.upgrade(req, socket, head)) socket.destroy(); });
230
+ const listen = (port = 8080, host?: string) => new Promise<string>((resolve, reject) => {
231
+ server.once("error", reject);
232
+ server.listen(port, host, () => { const a = server.address(); resolve(`http://${host && host !== "0.0.0.0" && host !== "::" ? host : "localhost"}:${typeof a === "object" && a ? a.port : port}/`); });
233
+ });
234
+ const close = async () => { const done = hub.shutdown(); heartbeat.stop(); server.close(); await done; };
235
+ return { server, hub, remote: remote!, listen, close };
236
+ }