tickcli-js 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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +360 -2
- package/client/game.js +982 -0
- package/client/game3d.js +109 -0
- package/client/relay.js +196 -0
- package/client/room.js +632 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +82 -0
- package/dist/cli.js.map +1 -0
- package/dist/hub.d.ts +619 -0
- package/dist/hub.js +1777 -0
- package/dist/hub.js.map +1 -0
- package/dist/node.d.ts +106 -0
- package/dist/node.js +301 -0
- package/dist/node.js.map +1 -0
- package/dist/remote.d.ts +75 -0
- package/dist/remote.js +214 -0
- package/dist/remote.js.map +1 -0
- package/dist/serve.d.ts +42 -0
- package/dist/serve.js +231 -0
- package/dist/serve.js.map +1 -0
- package/examples/games/arena.html +108 -0
- package/examples/games/arena3d.html +110 -0
- package/examples/games/chess.html +132 -0
- package/examples/games/cup.html +52 -0
- package/examples/games/delve.html +137 -0
- package/examples/games/dungeon.html +110 -0
- package/examples/games/neon-paint.html +79 -0
- package/examples/games/own-engine.html +41 -0
- package/examples/games/race.html +83 -0
- package/examples/games/shooter.html +95 -0
- package/examples/games/sky-duel.html +136 -0
- package/examples/games/space-squad.html +3627 -0
- package/examples/games/tag.html +51 -0
- package/examples/games/tictactoe.html +53 -0
- package/package.json +101 -4
- package/src/cli.ts +59 -0
- package/src/hub.ts +1521 -0
- package/src/node.ts +245 -0
- package/src/remote.ts +236 -0
- package/src/serve.ts +224 -0
package/src/serve.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// tickcli-js/serve: a complete game server in one call. Point it at a folder of .html games; every game gets live rooms, an invite link and
|
|
2
|
+
// anonymous players (a pass cookie names their seat). Plain Node (node:http + ws), no framework, no accounts.
|
|
3
|
+
//
|
|
4
|
+
// import { createGameServer } from "tickcli-js/serve";
|
|
5
|
+
// const app = createGameServer({ games: "./games", saves: "./saves" });
|
|
6
|
+
// await app.listen(8080); // http://localhost:8080/ lists the games
|
|
7
|
+
//
|
|
8
|
+
// A game page loads the libraries with any of: <script src="room.js">, <script src="lib:room">, <script src="tickcli/room.js"> (same for game.js
|
|
9
|
+
// and game3d.js). Engines installed next to tickcli load the same way: <script src="lib:three"></script> (global THREE, made from three's own
|
|
10
|
+
// ES module build at the first request, no bundler), lib:phaser (Phaser), lib:pixi (PIXI);
|
|
11
|
+
// the server points them at its own copies and runs the relay in the same window (the game is the top page, not a sandboxed frame).
|
|
12
|
+
// For signed-in players, your own routes or a sandboxed frame, build on RoomHub + tickcli-js/node instead (see the README, "Hosting it").
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import http from "node:http";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { WebSocketServer } from "ws";
|
|
20
|
+
import { RoomHub, type Limits, type SavePort } from "./hub.ts";
|
|
21
|
+
import { GuestPasses, Heartbeat, WS_OPTIONS, attach, clientAddress, fileSaves, gameHash, sameSite } from "./node.ts";
|
|
22
|
+
|
|
23
|
+
export interface GameServerOptions {
|
|
24
|
+
/** Folder of games, one .html file each (names: letters, digits, "-" and "_"). Default: the current folder. */
|
|
25
|
+
games?: string;
|
|
26
|
+
/** Where saved profiles and worlds go: a folder (file saves) or your own SavePort. Default: none, a restart forgets them. */
|
|
27
|
+
saves?: string | SavePort;
|
|
28
|
+
/** Signs the players' pass cookies. Keep it the same across restarts so players keep their seats and saves. Default: random per process. */
|
|
29
|
+
secret?: string | Buffer;
|
|
30
|
+
/** Room limits (players, rooms, sizes, rates); see DEFAULT_LIMITS. */
|
|
31
|
+
limits?: Partial<Limits>;
|
|
32
|
+
/** Mark the pass cookie Secure (set this when the site is served over https). */
|
|
33
|
+
secure?: boolean;
|
|
34
|
+
/** Heading of the game list page. */
|
|
35
|
+
title?: string;
|
|
36
|
+
/** Behind a proxy on this machine or a private network (Caddy, nginx): take each player's address from X-Forwarded-For, so the
|
|
37
|
+
* per-address limits count players, not the proxy. Only believed from private or loopback peers. */
|
|
38
|
+
trustProxy?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GameServer {
|
|
42
|
+
server: http.Server;
|
|
43
|
+
hub: RoomHub;
|
|
44
|
+
/** Starts listening; resolves with the address to open. */
|
|
45
|
+
listen(port?: number, host?: string): Promise<string>;
|
|
46
|
+
/** Stops the server; resolves once every save has been written. */
|
|
47
|
+
close(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CLIENT = fileURLToPath(new URL("../client/", import.meta.url));
|
|
51
|
+
const PASS = "cw_pass";
|
|
52
|
+
const SCOPE = "play";
|
|
53
|
+
const NAME_RE = /^[\w-]{1,60}\.html$/;
|
|
54
|
+
/** <script src="..."> forms a game may use for the two page libraries. */
|
|
55
|
+
const LIB_TAG = /<script\s+src=["'](?:lib:|(?:\.\/)?(?:tickcli(?:-js)?\/(?:client\/)?)?)(room|game|game3d)(?:\.js)?["']\s*>\s*<\/script>/gi;
|
|
56
|
+
/** Game engines a page may load with <script src="lib:NAME"></script> when their package is installed: package, its browser build (one classic
|
|
57
|
+
* script that sets a global; three.js has none, so it is made from its ES module build). */
|
|
58
|
+
const ENGINES: Record<string, { pkg: string; files: string[] }> = {
|
|
59
|
+
three: { pkg: "three", files: [] },
|
|
60
|
+
phaser: { pkg: "phaser", files: ["dist/phaser.min.js"] },
|
|
61
|
+
// PixiJS compiles its shaders with new Function unless its unsafe-eval add-on is there; game pages run under a CSP without eval
|
|
62
|
+
pixi: { pkg: "pixi.js", files: ["dist/pixi.min.js", "dist/packages/unsafe-eval.min.js"] },
|
|
63
|
+
};
|
|
64
|
+
const ENGINE_TAG = /<script\s+src=["']lib:(three|phaser|pixi)(?:\.js)?["']\s*>\s*<\/script>/gi;
|
|
65
|
+
/** An installed package's own folder (from this package or the folder the server runs in), or null. */
|
|
66
|
+
function pkgDir(name: string): string | null {
|
|
67
|
+
for (const from of [import.meta.url, path.join(process.cwd(), "x.js")]) {
|
|
68
|
+
let main: string;
|
|
69
|
+
try { main = createRequire(from).resolve(name); } catch { continue; }
|
|
70
|
+
for (let d = path.dirname(main); d !== path.dirname(d); d = path.dirname(d)) {
|
|
71
|
+
try { if (JSON.parse(fs.readFileSync(path.join(d, "package.json"), "utf8")).name === name) return d; } catch { /* keep looking up */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/** The browser build of an engine, or null when it is not installed (or three.js has an unknown shape). Read once. */
|
|
77
|
+
const engineCache = new Map<string, string | null>();
|
|
78
|
+
function engineSource(name: string): string | null {
|
|
79
|
+
if (!engineCache.has(name)) {
|
|
80
|
+
const e = ENGINES[name], dir = e ? pkgDir(e.pkg) : null;
|
|
81
|
+
let src: string | null = null;
|
|
82
|
+
if (dir) src = name === "three" ? threeGlobal(path.join(dir, "build"))
|
|
83
|
+
: e.files.every((f) => fs.existsSync(path.join(dir, f))) ? e.files.map((f) => fs.readFileSync(path.join(dir, f), "utf8").replace(/^\/\/# sourceMappingURL=.*$/gm, "")).join("\n;\n") : null;
|
|
84
|
+
engineCache.set(name, src);
|
|
85
|
+
}
|
|
86
|
+
return engineCache.get(name)!;
|
|
87
|
+
}
|
|
88
|
+
/** "A, B as C" from an export or import list: [[local name, exported name]]. */
|
|
89
|
+
function names(list: string): [string, string][] {
|
|
90
|
+
return list.split(",").map((x) => x.trim()).filter(Boolean).map((x) => { const m = /^(\S+)\s+as\s+(\S+)$/.exec(x); return m ? [m[1], m[2]] : [x, x]; });
|
|
91
|
+
}
|
|
92
|
+
/** three.js as one classic script that sets the global THREE, made from its ES module build (three.core.js + three.module.js: one export list,
|
|
93
|
+
* one import and one re-export from the core). Any other shape (a future three.js): null, so the page gets a clear 404 instead of a broken build. */
|
|
94
|
+
export function threeGlobal(dir: string): string | null {
|
|
95
|
+
const read = (f: string) => { try { return fs.readFileSync(path.join(dir, f), "utf8"); } catch { return null; } };
|
|
96
|
+
let core = read("three.core.js"), mod = read("three.module.js");
|
|
97
|
+
if (core === null || mod === null) return null;
|
|
98
|
+
const CORE = "['\"]\\./three\\.core\\.js['\"]";
|
|
99
|
+
const coreExp = new RegExp("^export \\{([^}]*)\\};?[ \\t]*$", "m").exec(core);
|
|
100
|
+
if (!coreExp) return null;
|
|
101
|
+
core = core.replace(coreExp[0], "");
|
|
102
|
+
const imp = new RegExp(`^import \\{([^}]*)\\} from ${CORE};?[ \\t]*$`, "m").exec(mod);
|
|
103
|
+
const reExp = new RegExp(`^export \\{([^}]*)\\} from ${CORE};?[ \\t]*$`, "m").exec(mod);
|
|
104
|
+
if (!imp || !reExp) return null;
|
|
105
|
+
mod = mod.replace(imp[0], "").replace(reExp[0], "");
|
|
106
|
+
const ownExp = new RegExp("^export \\{([^}]*)\\};?[ \\t]*$", "m").exec(mod);
|
|
107
|
+
if (!ownExp) return null;
|
|
108
|
+
mod = mod.replace(ownExp[0], "");
|
|
109
|
+
if (/^\s*(import|export)\b/m.test(core) || /^\s*(import|export)\b/m.test(mod) || /\bimport\.meta\b|\bimport\s*\(/.test(core + mod)) return null;
|
|
110
|
+
const obj = (pairs: [string, string][], from: (local: string) => string) => pairs.map(([l, e]) => `${JSON.stringify(e)}: ${from(l)}`).join(",\n");
|
|
111
|
+
return `/* three.js (MIT, three.js authors) as the global THREE: its ES module build wrapped by tickcli, nothing changed inside */\n` +
|
|
112
|
+
`var THREE = (function () {\n"use strict";\nvar __core = (function () {\n${core}\nreturn {\n${obj(names(coreExp[1]), (l) => l)}\n};\n})();\n` +
|
|
113
|
+
`return (function () {\nvar {${names(imp[1]).map(([l, e]) => `${JSON.stringify(l)}: ${e}`).join(", ")}} = __core;\n${mod}\n` +
|
|
114
|
+
`return Object.freeze({\n${obj(names(reExp[1]), (l) => `__core[${JSON.stringify(l)}]`)},\n${obj(names(ownExp[1]), (l) => l)}\n});\n})();\n})();\n`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The page's own socket address for its CSP (connect-src): only this server, as ws: and wss:. Plain 'self' covers same-origin sockets only in
|
|
118
|
+
* newer browsers, and "ws: wss:" alone allowed any host. A Host header that is not a plain host[:port] adds nothing. */
|
|
119
|
+
const wsOrigins = (req: http.IncomingMessage) => {
|
|
120
|
+
const h = String(req.headers.host || "");
|
|
121
|
+
return /^[a-z0-9.-]{1,253}(:\d{1,5})?$/i.test(h) || /^\[[0-9a-f:.]{2,45}\](:\d{1,5})?$/i.test(h) ? ` ws://${h} wss://${h}` : "";
|
|
122
|
+
};
|
|
123
|
+
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
124
|
+
const cookie = (req: http.IncomingMessage, name: string) => (req.headers.cookie || "").split(/;\s*/).map((p) => p.split("=")).find(([k]) => k === name)?.[1];
|
|
125
|
+
|
|
126
|
+
/** A game page as a visitor gets it: its library tags pointed at /client/*.js and the relay running in the same window. */
|
|
127
|
+
export function playPage(html: string, game: string, roomPath = "/room"): string {
|
|
128
|
+
const boot = `<script src="/client/relay.js"></script><script>tickcliRelay({frame:function(){return{contentWindow:window}},guest:true,` +
|
|
129
|
+
`name:function(){try{return localStorage.getItem("tickcli.name")||localStorage.getItem("hr.name")||""}catch(e){return ""}},onName:function(n){try{localStorage.setItem("tickcli.name",n);localStorage.removeItem("hr.name")}catch(e){}},` +
|
|
130
|
+
`connect:function(){return Promise.resolve({url:${JSON.stringify(`${roomPath}?game=${encodeURIComponent(game)}`)}})}})</script>`;
|
|
131
|
+
const out = html.replace(LIB_TAG, (_m, n: string) => `<script src="/client/${n.toLowerCase()}.js"></script>`).replace(ENGINE_TAG, (_m, n: string) => `<script src="/vendor/${n.toLowerCase()}.js"></script>`);
|
|
132
|
+
if (/<head[^>]*>/i.test(out)) return out.replace(/<head[^>]*>/i, (h) => h + boot);
|
|
133
|
+
// no <head>: right after the doctype, so the page stays in standards mode
|
|
134
|
+
return /^\s*<!doctype[^>]*>/i.test(out) ? out.replace(/^\s*<!doctype[^>]*>/i, (d) => d + boot) : boot + out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A complete game server: every .html file in `games` gets live rooms, an invite link and anonymous players. Plain node:http + ws.
|
|
139
|
+
*
|
|
140
|
+
* ```ts
|
|
141
|
+
* import { createGameServer } from "tickcli-js/serve";
|
|
142
|
+
* const app = createGameServer({ games: "./games", saves: "./saves" });
|
|
143
|
+
* await app.listen(8080); // http://localhost:8080/ lists the games
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
export function createGameServer(o: GameServerOptions = {}): GameServer {
|
|
147
|
+
const secret = o.secret === undefined ? crypto.randomBytes(32) : Buffer.from(o.secret);
|
|
148
|
+
const games = path.resolve(o.games ?? ".");
|
|
149
|
+
if (!fs.existsSync(games) || !fs.statSync(games).isDirectory()) throw new Error(`tickcli: no games folder at ${games}`);
|
|
150
|
+
const saves = typeof o.saves === "string" ? fileSaves(path.resolve(o.saves)) : o.saves;
|
|
151
|
+
const hub = new RoomHub({ secret: secret.toString("base64url"), limits: o.limits, saves });
|
|
152
|
+
hub.start();
|
|
153
|
+
const passes = new GuestPasses(secret);
|
|
154
|
+
const heartbeat = new Heartbeat();
|
|
155
|
+
const wss = new WebSocketServer({ ...WS_OPTIONS });
|
|
156
|
+
// A game is read and hashed once per version of its file (mtime + size), not on every page load and socket: after a restart every player
|
|
157
|
+
// reconnects within seconds, and a big game read and hashed 2,000 times would hold up the event loop.
|
|
158
|
+
const cache = new Map<string, { at: string; html: string; hash: string }>();
|
|
159
|
+
const read = (name: string) => {
|
|
160
|
+
if (!NAME_RE.test(name)) return null;
|
|
161
|
+
const file = path.join(games, name);
|
|
162
|
+
let st: fs.Stats;
|
|
163
|
+
try { st = fs.statSync(file); } catch { cache.delete(name); return null; }
|
|
164
|
+
if (!st.isFile()) return null;
|
|
165
|
+
const at = `${st.mtimeMs}|${st.size}`;
|
|
166
|
+
const hit = cache.get(name);
|
|
167
|
+
if (hit && hit.at === at) return hit;
|
|
168
|
+
const html = fs.readFileSync(file, "utf8");
|
|
169
|
+
const got = { at, html, hash: gameHash(html) };
|
|
170
|
+
cache.set(name, got);
|
|
171
|
+
return got;
|
|
172
|
+
};
|
|
173
|
+
const title = o.title ?? "tickcli games";
|
|
174
|
+
|
|
175
|
+
const server = http.createServer((req, res) => {
|
|
176
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
177
|
+
const send = (code: number, type: string, body: string, headers: Record<string, string> = {}) => { res.writeHead(code, { "Content-Type": type, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...headers }); res.end(body); };
|
|
178
|
+
if (req.method !== "GET" && req.method !== "HEAD") return send(405, "text/plain", "method not allowed");
|
|
179
|
+
if (url.pathname === "/") {
|
|
180
|
+
const list = fs.readdirSync(games).filter((f) => NAME_RE.test(f)).sort().map((f) => `<li><a href="/play/${f}">${esc(f.replace(/\.html$/, ""))}</a></li>`).join("");
|
|
181
|
+
return send(200, "text/html; charset=utf-8", `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title><h1>${esc(title)}</h1><ul>${list || "<li>No .html games in this folder yet.</li>"}</ul><p>Open a game in two windows (or send the link) to play together.</p>`);
|
|
182
|
+
}
|
|
183
|
+
const c = /^\/client\/(room|relay|game|game3d)\.js$/.exec(url.pathname);
|
|
184
|
+
if (c) return send(200, "text/javascript; charset=utf-8", fs.readFileSync(path.join(CLIENT, `${c[1]}.js`), "utf8"));
|
|
185
|
+
const v = /^\/vendor\/(three|phaser|pixi)\.js$/.exec(url.pathname);
|
|
186
|
+
if (v) {
|
|
187
|
+
const src = engineSource(v[1]);
|
|
188
|
+
if (!src) return send(404, "text/javascript; charset=utf-8", `console.error(${JSON.stringify(`lib:${v[1]}: install it next to tickcli (npm install ${ENGINES[v[1]].pkg})`)})`);
|
|
189
|
+
return send(200, "text/javascript; charset=utf-8", src, { "Cache-Control": "public, max-age=3600" });
|
|
190
|
+
}
|
|
191
|
+
const g = /^\/play\/([^/]+)$/.exec(url.pathname);
|
|
192
|
+
const html = g ? read(g[1])?.html : null;
|
|
193
|
+
if (!html) return send(404, "text/plain", "not found");
|
|
194
|
+
// An anonymous pass on first visit (scripts cannot read it; it only names a seat, it proves nothing about a person).
|
|
195
|
+
const headers: Record<string, string> = { "Content-Security-Policy": `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; connect-src 'self'${wsOrigins(req)}; frame-ancestors 'none'` };
|
|
196
|
+
if (!passes.check(SCOPE, cookie(req, PASS))) headers["Set-Cookie"] = GuestPasses.cookie(PASS, passes.issue(SCOPE), "/", !!o.secure);
|
|
197
|
+
return send(200, "text/html; charset=utf-8", playPage(html, g![1]), headers);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
server.on("upgrade", (req, socket, head) => {
|
|
201
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
202
|
+
const refuse = (code: number) => { socket.end(`HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); };
|
|
203
|
+
if (url.pathname !== "/room") return refuse(404);
|
|
204
|
+
if (!sameSite((n) => req.headers[n] as string | undefined)) return refuse(403);
|
|
205
|
+
const pass = passes.check(SCOPE, cookie(req, PASS));
|
|
206
|
+
if (!pass) return refuse(403);
|
|
207
|
+
const name = url.searchParams.get("game") || "";
|
|
208
|
+
const game = read(name);
|
|
209
|
+
if (!game) return refuse(404);
|
|
210
|
+
const identity = { scope: `${SCOPE}:${name}`, game: game.hash, slot: name, who: { seat: `g:${pass}`, name: GuestPasses.defaultName(pass), guest: true } };
|
|
211
|
+
wss.handleUpgrade(req, socket, head, (ws) => attach(ws, { hub, heartbeat, address: clientAddress(req, !!o.trustProxy), identity }));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const listen = (port = 8080, host?: string) => new Promise<string>((resolve, reject) => {
|
|
215
|
+
server.once("error", reject);
|
|
216
|
+
server.listen(port, host, () => {
|
|
217
|
+
const a = server.address();
|
|
218
|
+
const p = typeof a === "object" && a ? a.port : port;
|
|
219
|
+
resolve(`http://${!host || host === "0.0.0.0" || host === "::" ? "localhost" : host}:${p}/`);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
const close = async () => { const done = hub.shutdown(); heartbeat.stop(); wss.close(); server.close(); await done; };
|
|
223
|
+
return { server, hub, listen, close };
|
|
224
|
+
}
|