tickcli-js 0.1.1 → 0.1.3
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 +8 -0
- package/README.md +13 -2
- package/client/game.js +1 -1
- package/dist/hub.d.ts +26 -3
- package/dist/hub.js +80 -11
- package/dist/hub.js.map +1 -1
- package/dist/node.d.ts +11 -0
- package/dist/node.js +10 -1
- package/dist/node.js.map +1 -1
- package/dist/remote.d.ts +6 -0
- package/dist/remote.js +47 -9
- package/dist/remote.js.map +1 -1
- package/dist/serve.js +7 -4
- package/dist/serve.js.map +1 -1
- package/package.json +3 -3
- package/src/hub.ts +77 -12
- package/src/node.ts +14 -1
- package/src/remote.ts +38 -9
- package/src/serve.ts +6 -4
package/src/remote.ts
CHANGED
|
@@ -19,8 +19,8 @@ import crypto from "node:crypto";
|
|
|
19
19
|
import type { Duplex } from "node:stream";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
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";
|
|
22
|
+
import { CLOSE, RoomHub, addressKey, type Limits, type SavePort } from "./hub.ts";
|
|
23
|
+
import { GuestPasses, Heartbeat, WS_OPTIONS, attach, clientAddress, fileSaves, gameHash, hardenServer } from "./node.ts";
|
|
24
24
|
|
|
25
25
|
export interface Deployment {
|
|
26
26
|
/** public id, as in the page's script tag: letters, digits, "_" and "-", 3 to 64 */
|
|
@@ -59,6 +59,8 @@ export interface Remote {
|
|
|
59
59
|
request(req: http.IncomingMessage, res: http.ServerResponse): boolean;
|
|
60
60
|
/** Turn a deployment off now: its open rooms close (saves are written first) and its cached settings are dropped. */
|
|
61
61
|
revoke(id: string): void;
|
|
62
|
+
/** Drop the cached settings of one deployment (after its sites or caps changed) without closing its rooms. Unchanged ones refresh every LOOKUP_MS. */
|
|
63
|
+
forget(id: string): void;
|
|
62
64
|
/** The hub namespace of a deployment (for hub.scopeStats, hub.closeScope). */
|
|
63
65
|
scope(id: string): string;
|
|
64
66
|
/** The rooms cap the hub should use for a namespace (pass to new RoomHub({ roomsPerScope })). */
|
|
@@ -68,6 +70,10 @@ export interface Remote {
|
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
const ID_RE = /^[A-Za-z0-9_-]{3,64}$/;
|
|
73
|
+
/** A deployment (found or not) is asked for again after this long; call forget(id) to make an edit count at once. */
|
|
74
|
+
export const LOOKUP_MS = 15_000;
|
|
75
|
+
/** Fresh lookups one client address (IPv6: /64) may cause per 10 s. */
|
|
76
|
+
export const LOOKUPS_PER_ADDRESS = 20;
|
|
71
77
|
const GAME_RE = /^[A-Za-z0-9_-]{1,60}$/;
|
|
72
78
|
const VER_RE = /^[A-Za-z0-9._-]{1,40}$/;
|
|
73
79
|
const KEY_RE = /^[A-Za-z0-9_-]{22,64}$/;
|
|
@@ -134,13 +140,33 @@ export function createRemote(o: RemoteOptions): Remote {
|
|
|
134
140
|
const sources = { relay: src("relay"), room: src("room"), game: src("game") };
|
|
135
141
|
const files: Record<string, string> = { relay: sources.relay, room: sources.room, game: sources.game, game3d: src("game3d") };
|
|
136
142
|
const scope = (id: string) => `dep:${id}`;
|
|
137
|
-
|
|
143
|
+
// Deployments are looked up at most once per LOOKUP_MS per id (found or not), and an address may cause at most LOOKUPS_PER_ADDRESS fresh lookups
|
|
144
|
+
// per 10 s: a flood of sockets or boot scripts with random ids costs a map hit, not a query on your table per request.
|
|
145
|
+
const seen = new Map<string, number>(); // id -> when it was last looked up
|
|
146
|
+
const misses = new Map<string, { at: number; n: number }>();
|
|
147
|
+
const lookupAllowed = (addr: string) => {
|
|
148
|
+
const k = addressKey(addr), now = Date.now();
|
|
149
|
+
let w = misses.get(k);
|
|
150
|
+
if (!w || now - w.at >= 10_000) {
|
|
151
|
+
if (misses.size >= 50_000) for (const [x, v] of misses) if (now - v.at >= 10_000) misses.delete(x);
|
|
152
|
+
if (misses.size >= 50_000) return false;
|
|
153
|
+
w = { at: now, n: 0 }; misses.set(k, w);
|
|
154
|
+
}
|
|
155
|
+
return ++w.n <= LOOKUPS_PER_ADDRESS;
|
|
156
|
+
};
|
|
157
|
+
const lookup = async (id: string, addr: string) => {
|
|
138
158
|
if (!ID_RE.test(id)) return null;
|
|
159
|
+
const at = seen.get(id);
|
|
160
|
+
if (at !== undefined && Date.now() - at < LOOKUP_MS) return cache.get(id) ?? null;
|
|
161
|
+
if (!lookupAllowed(addr)) return cache.get(id) ?? null;
|
|
139
162
|
let dep: Deployment | null | undefined = null;
|
|
140
163
|
try { dep = await o.deployment(id); } catch { dep = null; }
|
|
164
|
+
if (seen.size >= 100_000) seen.clear();
|
|
165
|
+
seen.set(id, Date.now());
|
|
141
166
|
if (dep && dep.id === id) cache.set(id, dep); else cache.delete(id);
|
|
142
167
|
return dep && dep.id === id ? dep : null;
|
|
143
168
|
};
|
|
169
|
+
const addrOf = (req: http.IncomingMessage) => (o.address ? o.address(req) : req.socket.remoteAddress || "?");
|
|
144
170
|
// 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
171
|
const closeWith = (req: http.IncomingMessage, socket: Duplex, head: Buffer, code: number, reason: string) =>
|
|
146
172
|
wss.handleUpgrade(req, socket, head, (ws: WebSocket) => ws.close(code, reason));
|
|
@@ -149,7 +175,8 @@ export function createRemote(o: RemoteOptions): Remote {
|
|
|
149
175
|
scope,
|
|
150
176
|
sockets: (id) => live.get(id) || 0,
|
|
151
177
|
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"); },
|
|
178
|
+
revoke(id) { cache.delete(id); seen.delete(id); o.hub.closeScope(scope(id), "this game was turned off by its owner"); },
|
|
179
|
+
forget(id) { cache.delete(id); seen.delete(id); },
|
|
153
180
|
|
|
154
181
|
upgrade(req, socket, head) {
|
|
155
182
|
const url = new URL(req.url || "/", "http://localhost");
|
|
@@ -159,7 +186,9 @@ export function createRemote(o: RemoteOptions): Remote {
|
|
|
159
186
|
const id = url.searchParams.get("d") || "", game = url.searchParams.get("game") || "", ver = url.searchParams.get("v") || "1";
|
|
160
187
|
if (!GAME_RE.test(game) || !VER_RE.test(ver)) { closeWith(req, socket, head, CLOSE.badMessage, "bad game name or version"); return true; }
|
|
161
188
|
socket.on("error", () => { /* closed early */ });
|
|
162
|
-
|
|
189
|
+
const addr = addrOf(req);
|
|
190
|
+
if (!o.hub.allowAddress(addr)) { closeWith(req, socket, head, CLOSE.busy, "too many new connections, try again"); return true; }
|
|
191
|
+
void lookup(id, addr).then((dep) => {
|
|
163
192
|
if (socket.destroyed) return;
|
|
164
193
|
if (!dep || dep.revoked) return closeWith(req, socket, head, CLOSE.notAllowed, "this game's deployment is unknown or turned off");
|
|
165
194
|
if (!originAllowed(dep, origin)) return closeWith(req, socket, head, CLOSE.notAllowed, `this site is not allowed for deployment ${id}`);
|
|
@@ -170,7 +199,7 @@ export function createRemote(o: RemoteOptions): Remote {
|
|
|
170
199
|
live.set(id, (live.get(id) || 0) + 1);
|
|
171
200
|
ws.on("close", () => { const left = (live.get(id) || 1) - 1; if (left > 0) live.set(id, left); else live.delete(id); });
|
|
172
201
|
attach(ws, {
|
|
173
|
-
hub: o.hub, heartbeat: o.heartbeat, address:
|
|
202
|
+
hub: o.hub, heartbeat: o.heartbeat, address: addr, counted: true,
|
|
174
203
|
// 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
204
|
// this deployment only (the hub shows others a short HMAC of it, never the key)
|
|
176
205
|
authenticate: (ticket) => typeof ticket === "string" && KEY_RE.test(ticket)
|
|
@@ -193,7 +222,7 @@ export function createRemote(o: RemoteOptions): Remote {
|
|
|
193
222
|
if (c) { js(200, files[c[1]], "public, max-age=300"); return true; }
|
|
194
223
|
const b = /^\/c\/([^/]+)\.js$/.exec(url.pathname);
|
|
195
224
|
if (!b) return false;
|
|
196
|
-
void lookup(b[1]).then((dep) => {
|
|
225
|
+
void lookup(b[1], addrOf(req)).then((dep) => {
|
|
197
226
|
if (!dep || dep.revoked) return js(404, `console.error(${JSON.stringify(`tickcli: no deployment ${b[1].slice(0, 64)} (unknown or turned off)`)});`);
|
|
198
227
|
js(200, bootScript(dep.id, sources), "public, max-age=300");
|
|
199
228
|
});
|
|
@@ -222,10 +251,10 @@ export function createRemoteServer(o: RemoteServerOptions) {
|
|
|
222
251
|
hub.start();
|
|
223
252
|
const heartbeat = new Heartbeat();
|
|
224
253
|
remote = createRemote({ hub, deployment: o.deployment, heartbeat, address: o.address ?? (o.trustProxy ? (req) => clientAddress(req, true) : undefined) });
|
|
225
|
-
const server = http.createServer((req, res) => {
|
|
254
|
+
const server = hardenServer(http.createServer((req, res) => {
|
|
226
255
|
if (remote!.request(req, res)) return;
|
|
227
256
|
res.writeHead(404, { "Content-Type": "text/plain" }); res.end("not found");
|
|
228
|
-
});
|
|
257
|
+
}));
|
|
229
258
|
server.on("upgrade", (req, socket, head) => { if (!remote!.upgrade(req, socket, head)) socket.destroy(); });
|
|
230
259
|
const listen = (port = 8080, host?: string) => new Promise<string>((resolve, reject) => {
|
|
231
260
|
server.once("error", reject);
|
package/src/serve.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { createRequire } from "node:module";
|
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { WebSocketServer } from "ws";
|
|
20
20
|
import { RoomHub, type Limits, type SavePort } from "./hub.ts";
|
|
21
|
-
import { GuestPasses, Heartbeat, WS_OPTIONS, attach, clientAddress, fileSaves, gameHash, sameSite } from "./node.ts";
|
|
21
|
+
import { GuestPasses, Heartbeat, WS_OPTIONS, attach, clientAddress, fileSaves, gameHash, hardenServer, sameSite } from "./node.ts";
|
|
22
22
|
|
|
23
23
|
export interface GameServerOptions {
|
|
24
24
|
/** Folder of games, one .html file each (names: letters, digits, "-" and "_"). Default: the current folder. */
|
|
@@ -172,7 +172,7 @@ export function createGameServer(o: GameServerOptions = {}): GameServer {
|
|
|
172
172
|
};
|
|
173
173
|
const title = o.title ?? "tickcli games";
|
|
174
174
|
|
|
175
|
-
const server = http.createServer((req, res) => {
|
|
175
|
+
const server = hardenServer(http.createServer((req, res) => {
|
|
176
176
|
const url = new URL(req.url || "/", "http://localhost");
|
|
177
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
178
|
if (req.method !== "GET" && req.method !== "HEAD") return send(405, "text/plain", "method not allowed");
|
|
@@ -195,12 +195,14 @@ export function createGameServer(o: GameServerOptions = {}): GameServer {
|
|
|
195
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
196
|
if (!passes.check(SCOPE, cookie(req, PASS))) headers["Set-Cookie"] = GuestPasses.cookie(PASS, passes.issue(SCOPE), "/", !!o.secure);
|
|
197
197
|
return send(200, "text/html; charset=utf-8", playPage(html, g![1]), headers);
|
|
198
|
-
});
|
|
198
|
+
}));
|
|
199
199
|
|
|
200
200
|
server.on("upgrade", (req, socket, head) => {
|
|
201
201
|
const url = new URL(req.url || "/", "http://localhost");
|
|
202
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
203
|
if (url.pathname !== "/room") return refuse(404);
|
|
204
|
+
const address = clientAddress(req, !!o.trustProxy);
|
|
205
|
+
if (!hub.allowAddress(address)) return refuse(429);
|
|
204
206
|
if (!sameSite((n) => req.headers[n] as string | undefined)) return refuse(403);
|
|
205
207
|
const pass = passes.check(SCOPE, cookie(req, PASS));
|
|
206
208
|
if (!pass) return refuse(403);
|
|
@@ -208,7 +210,7 @@ export function createGameServer(o: GameServerOptions = {}): GameServer {
|
|
|
208
210
|
const game = read(name);
|
|
209
211
|
if (!game) return refuse(404);
|
|
210
212
|
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:
|
|
213
|
+
wss.handleUpgrade(req, socket, head, (ws) => attach(ws, { hub, heartbeat, address, counted: true, identity }));
|
|
212
214
|
});
|
|
213
215
|
|
|
214
216
|
const listen = (port = 8080, host?: string) => new Promise<string>((resolve, reject) => {
|