@homie-rocks/studio 0.55.0 → 0.56.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 +39 -0
- package/README.md +2 -0
- package/bin/homie-studio.mjs +1 -1
- package/docs/custom-routes.md +72 -0
- package/guides/app/SKILL.md +7 -0
- package/guides/playtest/scripts/playtest.mjs +7 -5
- package/lib/check.mjs +8 -1
- package/lib/cloudflare.mjs +49 -22
- package/lib/dev.mjs +6 -8
- package/lib/local-database-start.mjs +6 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/port-check.mjs +3 -2
- package/lib/scaffold.mjs +2 -0
- package/lib/template-history.json +46 -1
- package/lib/worker-build.mjs +4 -1
- package/lib/worker-config.mjs +7 -0
- package/netplay/NETPLAY.md +5 -1
- package/netplay/netplay.ts +24 -9
- package/package.json +1 -1
- package/rules/core.ts +9 -0
- package/rules/host.ts +40 -11
- package/rules/view.ts +1 -0
- package/tools/TOOLS.md +6 -1
- package/worker/background-queue.mjs +1 -0
- package/worker/build-id.mjs +1 -1
- package/worker/custom-routes.mjs +16 -0
- package/worker/gate.mjs +94 -23
- package/worker/idle-chat.mjs +56 -0
- package/worker/index.mjs +2 -0
- package/worker/lazy-objects.mjs +3 -0
- package/worker/lobby-peak.mjs +44 -0
- package/worker/lobby-shards.mjs +43 -0
- package/worker/lobby-store.mjs +35 -0
- package/worker/lobby-summary.mjs +28 -0
- package/worker/lobby.mjs +86 -18
- package/worker/lounge-store.mjs +2 -2
- package/worker/match-router.mjs +74 -0
- package/worker/mcp-room.mjs +2 -1
- package/worker/mcp-tools.mjs +4 -2
- package/worker/office.mjs +2 -1
- package/worker/operational-schema.mjs +2 -0
- package/worker/pages.mjs +1 -1
- package/worker/relay-channel.mjs +119 -0
- package/worker/request-db.mjs +4 -3
- package/worker/room-chat.mjs +2 -1
- package/worker/room-sessions.mjs +65 -0
- package/worker/room.mjs +16 -9
- package/worker/route-auth.mjs +22 -0
- package/worker/routes.mjs +7 -4
- package/worker/runtime-support.mjs +5 -3
- package/worker/session-codec.mjs +24 -0
- package/worker/stats.mjs +2 -1
- package/worker/table.mjs +277 -41
- package/worker/version.mjs +1 -1
- package/worker/watch-sockets.mjs +75 -0
package/worker/gate.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {WatchSockets} from './watch-sockets.mjs';
|
|
2
|
+
import {isQa} from './stats.mjs';
|
|
3
|
+
import {relayChannel,readRelay,retryRelay} from './relay-channel.mjs';
|
|
1
4
|
import { STUDIO_BUILD } from './build-id.mjs';
|
|
2
5
|
import { stageTimings } from '../rules/timing.mjs';
|
|
3
6
|
import { crowdView, crowdWorlds, scheduledView, snapshotEncoder, snapshotText } from '../rules/interest.mjs';
|
|
@@ -38,13 +41,13 @@ export function* expandBatch(rows) {
|
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
export function batchLink(socket, { setTimer = setTimeout, clearTimer = clearTimeout, delay = 1, receipts = false, maxFrames = 4, maxBytes = 4 * 1024 * 1024, onWritable = () => {}, autoReceipt = true, now = Date.now, world = null, onAck = () => {} } = {}) {
|
|
41
|
-
let queue = [], views = new Map(), strings = new Set(), timer = null, closed = false, bytes = 0, sequence = 0, inFlightBytes = 0, blockedByBytes = false, receipt = 0, receiptAt = 0, stamp = null, lastSentAt = -Infinity;
|
|
44
|
+
let queue = [], views = new Map(), strings = new Set(), timer = null, closed = false, bytes = 0, sequence = 0, inFlightBytes = 0, blockedByBytes = false, blockedByTransport = false, receipt = 0, receiptAt = 0, stamp = null, lastSentAt = -Infinity;
|
|
42
45
|
const pending = new Map();
|
|
43
46
|
const timing = stageTimings();
|
|
44
47
|
const stats = { frames: 0, rows: 0, bytes: 0, peakQueueBytes: 0, coalesced: 0, ackMs: 0, maxAckMs: 0, rttMs: 0 };
|
|
45
48
|
const close = () => { closed = true; queue = []; views.clear(); strings.clear(); pending.clear(); inFlightBytes = 0; bytes = 0; if (timer !== null) clearTimer(timer); timer = null; };
|
|
46
49
|
const fail = (reason) => { close(); try { socket.close(1011, reason); } catch { /* The peer already closed; local shutdown still runs. */ } };
|
|
47
|
-
const schedule = () => { if (!closed && (receipt || pending.size < maxFrames && !blockedByBytes && (queue.length || views.size)) && timer === null) timer = setTimer(flush, delay > 1 ? Math.max(0, lastSentAt + delay - now()) : delay); };
|
|
50
|
+
const schedule = () => { if (!closed && (receipt || pending.size < maxFrames && !blockedByBytes && !blockedByTransport && (queue.length || views.size)) && timer === null) timer = setTimer(flush, delay > 1 ? Math.max(0, lastSentAt + delay - now()) : delay); };
|
|
48
51
|
const flush = () => {
|
|
49
52
|
if (timer !== null) clearTimer(timer); timer = null;
|
|
50
53
|
if (closed) return;
|
|
@@ -79,12 +82,14 @@ export function batchLink(socket, { setTimer = setTimeout, clearTimer = clearTim
|
|
|
79
82
|
const size = text.length;
|
|
80
83
|
if (size > maxBytes) { fail('room link frame limit'); return; }
|
|
81
84
|
if (receipts && inFlightBytes + size > maxBytes) { blockedByBytes = true; return; }
|
|
85
|
+
if(socket.canSend && !socket.canSend(size,text)){blockedByTransport=true;return;}
|
|
82
86
|
queue = []; views.clear(); strings.clear(); bytes = 0; receipt = 0;
|
|
83
87
|
if (receipts && hasPayload) { pending.set(++sequence, { at: now(), bytes: size }); inFlightBytes += size; }
|
|
84
88
|
stats.frames++; stats.rows += rows.length; stats.bytes += size;
|
|
85
89
|
const sendAt = timing.now();
|
|
86
90
|
try { lastSentAt = now(); socket.send(text); if (worldSeq) world.sent(worldSeq); timing.record('send', sendAt); onWritable(); } catch { fail('room link send failed'); }
|
|
87
91
|
};
|
|
92
|
+
socket.addEventListener('drain',()=>{blockedByTransport=false;schedule();});
|
|
88
93
|
if (receipts) socket.addEventListener('message', event => {
|
|
89
94
|
if (typeof event.data !== 'string') return;
|
|
90
95
|
try {
|
|
@@ -124,6 +129,7 @@ export function batchLink(socket, { setTimer = setTimeout, clearTimer = clearTim
|
|
|
124
129
|
if (inMs !== null && !closed) { if (timer !== null) clearTimer(timer); timer = setTimer(flush, Math.max(0, inMs)); }
|
|
125
130
|
else schedule();
|
|
126
131
|
},
|
|
132
|
+
reset() { pending.clear(); sequence=0; receipt=0; inFlightBytes=0; blockedByBytes=false; schedule(); },
|
|
127
133
|
flush, close, forget: id => views.delete(id), buffered: () => bytes,
|
|
128
134
|
/** This link's recent round trip, ms: the newest receipt's, or a larger recent one fading out. */
|
|
129
135
|
rtt: () => stats.rttMs,
|
|
@@ -133,12 +139,12 @@ export function batchLink(socket, { setTimer = setTimeout, clearTimer = clearTim
|
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
/** Adapt one upstream socket into the same transport the Table accepts locally. */
|
|
136
|
-
export function multiplexSession(socket, connect, { onMetrics = () => {}, delay = 60, source = worldSource(), onCount = () => {} } = {}) {
|
|
142
|
+
export function multiplexSession(socket, connect, { onMetrics = () => {}, delay = 60, source = worldSource(), onCount = () => {}, receipts = true, lastPing = () => 0, resume = () => null, remember = () => {} } = {}) {
|
|
137
143
|
// Eight frames may be unacknowledged: 400 ms of 20 Hz worlds. Past that the newest world replaces an unsent one.
|
|
138
144
|
// This link's Gate has every world up to `synced`, and holds the players in these seats.
|
|
139
145
|
let synced = 0; const seats = new Map();
|
|
140
146
|
const world = Object.assign((row) => { const w = source.encode(row[2]); return w ? { row: ['world', '*', source.header(w, synced, seats.values()), row[3], row[4], w.block], seq: w.seq } : null; }, { sent(seq) { synced = seq; } });
|
|
141
|
-
const out = batchLink(socket, { receipts
|
|
147
|
+
const out = batchLink(socket, { receipts, delay, maxFrames: 8, autoReceipt: false, world, onWritable: () => scheduleAdmission() }), clients = new Map();
|
|
142
148
|
let chain = Promise.resolve(), closed = false;
|
|
143
149
|
const group = { snapshot(snap, settings, hz) { out.send(['world', '*', snap, settings, hz]); out.flush(); }, subscribe(id, seat) { seats.set(id, seat); out.send(['seat', id, seat]); }, facts: () => out.facts() };
|
|
144
150
|
const pending = new Set(), admissions = [];
|
|
@@ -176,12 +182,16 @@ export function multiplexSession(socket, connect, { onMetrics = () => {}, delay
|
|
|
176
182
|
if (!Array.isArray(rows)) throw new Error('invalid multiplex batch');
|
|
177
183
|
heard.frames += 1; heard.rows += rows.length; heard.bytes += event.data.length;
|
|
178
184
|
for (const [op, id, value, ip, agent] of expandBatch(rows)) {
|
|
185
|
+
if (op === 'resync') { synced=0; continue; }
|
|
179
186
|
if (op === 'metrics') { onMetrics(id, value); continue; }
|
|
180
187
|
if (op === 'open') {
|
|
181
188
|
if (clients.has(id)) throw new Error('duplicate multiplex client');
|
|
182
189
|
const handlers = new Map();
|
|
183
190
|
const client = {
|
|
184
191
|
accept() {},
|
|
192
|
+
resume: resume(id),
|
|
193
|
+
lastPing: () => lastPing(id),
|
|
194
|
+
remember(clientId) { remember(id, clientId, value, ip, agent); },
|
|
185
195
|
addEventListener(name, fn) { const list = handlers.get(name) ?? []; list.push(fn); handlers.set(name, list); },
|
|
186
196
|
emit(name, event) { for (const fn of handlers.get(name) ?? []) fn(event); },
|
|
187
197
|
send(text) { out.send(['data', id, text]); },
|
|
@@ -196,7 +206,7 @@ export function multiplexSession(socket, connect, { onMetrics = () => {}, delay
|
|
|
196
206
|
client.ready = new Promise((resolve, reject) => {
|
|
197
207
|
const admit = async () => {
|
|
198
208
|
if (closed || !clients.has(id)) { resolve(); return; }
|
|
199
|
-
try { await connect(new Request(value, { headers: { Upgrade: 'websocket', ...(ip ? { 'cf-connecting-ip': ip } : {}), ...(agent ? { 'user-agent': agent } : {}) } }), client); client.attached = true; resolve(); }
|
|
209
|
+
try { const response=await connect(new Request(value, { headers: { Upgrade: 'websocket', ...(ip ? { 'cf-connecting-ip': ip } : {}), ...(agent ? { 'user-agent': agent } : {}) } }), client); if(response!=null)client.close(1012,'room restore rejected');else client.attached = true; resolve(); }
|
|
200
210
|
catch (error) { reject(error); }
|
|
201
211
|
};
|
|
202
212
|
admit.cancel = resolve; admissions.push(admit);
|
|
@@ -227,7 +237,7 @@ export function multiplexSession(socket, connect, { onMetrics = () => {}, delay
|
|
|
227
237
|
});
|
|
228
238
|
socket.addEventListener('close', shutdown);
|
|
229
239
|
socket.addEventListener('error', shutdown);
|
|
230
|
-
return { group, shutdown, settled: async () => { await chain; await Promise.allSettled([...pending]); } };
|
|
240
|
+
return { group, shutdown, flush:out.flush, reset() { synced=0;out.reset();for(const [id,seat] of seats)out.send(['seat',id,seat]); }, settled: async () => { await chain; await Promise.allSettled([...pending]); } };
|
|
231
241
|
}
|
|
232
242
|
export function acceptMultiplex(request, connect, options) {
|
|
233
243
|
const [client, server] = Object.values(new WebSocketPair());
|
|
@@ -285,7 +295,16 @@ const COASTS = /^\{"t":"hello".{0,2000}?"coast":1[,}]/;
|
|
|
285
295
|
const percentile = (sorted, p) => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : null;
|
|
286
296
|
|
|
287
297
|
export class Gate {
|
|
288
|
-
constructor(ctx, env) { this.ctx = ctx; this.env = env; this.clients = new Map(); this.admissions = new Map(); this.sequence = 0; this.link = null; this.opening = null; this.timing = stageTimings(); this.metricsAt = 0; this.window = this.freshWindow(); this.worldTick = null; this.behind = new Set(); this.idle = null;
|
|
298
|
+
constructor(ctx, env) { this.ctx = ctx; this.env = env; this.clients = new Map(); this.admissions = new Map(); this.sequence = 0; this.link = null; this.opening = null; this.timing = stageTimings(); this.metricsAt = 0; this.window = this.freshWindow(); this.worldTick = null; this.behind = new Set(); this.idle = null;
|
|
299
|
+
this.relayEpoch = crypto.randomUUID();
|
|
300
|
+
this.watchSockets = new WatchSockets(ctx);
|
|
301
|
+
ctx.blockConcurrencyWhile?.(async () => {
|
|
302
|
+
await this.watchSockets.restore(async (request, transport, attachment) => {
|
|
303
|
+
transport.gateState = attachment.gate;
|
|
304
|
+
await this.connect(request, transport);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
}
|
|
289
308
|
freshWindow() { return { worlds: 0, frames: 0, bytes: 0, skipped: 0, inputs: 0, worldAge: [], tickFrom: null, tickFromAt: 0, tickAt: 0, gapMax: 0, gaps: [0, 0, 0], late: 0, busy: 0 }; }
|
|
290
309
|
/** This Gate's own lateness: a timer every 20 ms, and how long past it each one came. A Gate that is out of time is late to its own timer. */
|
|
291
310
|
watchLoop() {
|
|
@@ -294,7 +313,9 @@ export class Gate {
|
|
|
294
313
|
this.loopTimer = setInterval(() => {
|
|
295
314
|
const now = Date.now(), late = Math.max(0, now - due); due = now + 20;
|
|
296
315
|
this.window.late = Math.max(this.window.late, late); if (late > 20) this.window.busy += 1;
|
|
297
|
-
|
|
316
|
+
// Diagnostics need a clock only while worlds are being delivered. An
|
|
317
|
+
// idle audience must not keep fifty diagnostic callbacks a second alive.
|
|
318
|
+
if (!this.link || !this.clients.size || now - (this.worldAt ?? 0) > 1000) { clearInterval(this.loopTimer); this.loopTimer = null; }
|
|
298
319
|
}, 20);
|
|
299
320
|
this.loopTimer?.unref?.();
|
|
300
321
|
}
|
|
@@ -315,15 +336,25 @@ export class Gate {
|
|
|
315
336
|
// and was the first object to fall behind; sixteen channels of one frame
|
|
316
337
|
// a tick each way are cheaper for the room than two of hundreds.
|
|
317
338
|
const target = new URL(url); target.pathname = '/__multiplex';
|
|
318
|
-
|
|
319
|
-
if (
|
|
320
|
-
|
|
339
|
+
let socket;
|
|
340
|
+
if ((url.searchParams.get('relay') === '1' || url.searchParams.get('host') === 'server')) {
|
|
341
|
+
target.pathname = '/__relay';
|
|
342
|
+
const epoch = this.relayEpoch = crypto.randomUUID();
|
|
343
|
+
socket = relayChannel(this.ctx, (data,sequence) => this.env.TABLE.get(this.env.TABLE.idFromName(name)).fetch(target, {method:'POST',headers:{'x-homie-relay-epoch':epoch,'x-homie-relay-sequence':String(sequence)},body:data}));
|
|
344
|
+
this.relaySocket = socket;
|
|
345
|
+
socket.addEventListener('detached',()=>{ /* The next input or room alarm crosses the idle boundary. */ });
|
|
346
|
+
} else {
|
|
347
|
+
const response = await this.env.TABLE.get(this.env.TABLE.idFromName(name)).fetch(new Request(target, { headers: { Upgrade: 'websocket' } }));
|
|
348
|
+
if (!response.webSocket) throw new Error('room link refused');
|
|
349
|
+
socket = response.webSocket; socket.accept();
|
|
350
|
+
}
|
|
321
351
|
// What players send leaves this Gate as one frame at most every 50 ms: at once when the channel has been
|
|
322
352
|
// quiet, else with whatever arrived since the last. The room's one thread is spent by the message, so a
|
|
323
353
|
// Gate costs it one a tick each way. (Timing that frame to land just before the room's tick was tried twice
|
|
324
354
|
// and failed twice on Cloudflare: the round trip to the room varies by more than the window it aims for.)
|
|
325
|
-
const link = batchLink(socket, { receipts:
|
|
326
|
-
|
|
355
|
+
const link = batchLink(socket, { receipts: socket !== this.relaySocket, delay: 50, maxFrames: 8, autoReceipt: false, onAck: (waited, clock) => { if (clock) this.roomClock(clock); } });
|
|
356
|
+
let sink = worldSink(), worldReady = false;
|
|
357
|
+
socket.addEventListener('reset',()=>{link.reset();sink=worldSink();worldReady=false;this.worlds=null;for(const client of this.clients.values()){client.snapEncoder=null;client.crowd=null;}link.send(['resync']);});
|
|
327
358
|
const lost = (event = {}) => {
|
|
328
359
|
if (this.link !== link) return;
|
|
329
360
|
console.log(JSON.stringify({ ev: 'room-link-lost', room: name, gate: url.searchParams.get('gate'), layer: 'gate', code: event.code ?? null, reason: event.reason ?? null, error: event.error?.message ?? null, clients: this.clients.size, transport: link.facts() }));
|
|
@@ -338,16 +369,21 @@ export class Gate {
|
|
|
338
369
|
try {
|
|
339
370
|
const rows = parseFrame(event); let hz = null;
|
|
340
371
|
for (const row of rows) {
|
|
341
|
-
if
|
|
372
|
+
if(row[0]==='link-close'){lost({code:row[1],reason:row[2]});return;}
|
|
373
|
+
if (row[0] === 'world') { hz = row[4]; if(row[2].w && !worldReady && !row[2].w.ids){link.send(['resync']);continue;} worldReady=true; const snap = row[2].w ? sink.decode(row[2], row[5]) : row[2]; this.deliver(snap, row[3], row[4], drop); continue; }
|
|
342
374
|
for (const [op, id, value, reason, settings, rate] of expandBatch([row])) {
|
|
343
375
|
const client = this.clients.get(id);
|
|
344
|
-
if (op === 'seat') { if (client) { client.seat = value; client.subscribed = true; } }
|
|
376
|
+
if (op === 'seat') { if (client) { client.seat = value; client.subscribed = true; client.rememberGate?.({...client.gateState,seat:value,subscribed:true}); } }
|
|
345
377
|
else if (op === 'view') { if (client) this.deliverOne(id, client, value, reason, settings, rate, drop); }
|
|
346
378
|
else if (op === 'data') { try { client?.send(value); } catch { if (client) drop(id, client); } }
|
|
347
379
|
else if (op === 'close') { this.clients.delete(id); try { client?.close(value, reason); } catch { /* The peer already closed; local shutdown still runs. */ } }
|
|
348
380
|
}
|
|
349
381
|
}
|
|
350
382
|
link.consumed(rows);
|
|
383
|
+
if(socket===this.relaySocket)for(const [id,client] of this.clients){
|
|
384
|
+
const ping=client.lastPing?.()??0;
|
|
385
|
+
if(ping>(client.forwardedPing??0)){client.forwardedPing=ping;link.send(['data',id,JSON.stringify({t:'ping',c:ping})]);}
|
|
386
|
+
}
|
|
351
387
|
if (hz && Date.now() - this.metricsAt >= 1000) this.publishMetrics(url, link);
|
|
352
388
|
} catch (error) { lost({ error, reason: 'invalid room frame' }); try { socket.close(1011, 'room link failed'); } catch { /* The peer already closed; local shutdown still runs. */ } }
|
|
353
389
|
});
|
|
@@ -359,7 +395,7 @@ export class Gate {
|
|
|
359
395
|
this.link = null; this.worlds = null; link.close();
|
|
360
396
|
try { socket.close(1000, 'no players'); } catch { /* gone */ }
|
|
361
397
|
};
|
|
362
|
-
this.link = link; return link;
|
|
398
|
+
this.link = link; if(socket===this.relaySocket)this.openRelay(url,socket); return link;
|
|
363
399
|
})();
|
|
364
400
|
try { return await this.opening; } finally { this.opening = null; }
|
|
365
401
|
}
|
|
@@ -369,6 +405,7 @@ export class Gate {
|
|
|
369
405
|
if (w.tickFrom === null) { w.tickFrom = this.worldTick ?? snap.k; w.tickFromAt = this.worldAt ?? now; }
|
|
370
406
|
if (this.worldAt) { const gap = now - this.worldAt; w.gapMax = Math.max(w.gapMax, gap); if (gap > 75) w.gaps[0] += 1; if (gap > 150) w.gaps[1] += 1; if (gap > 300) w.gaps[2] += 1; }
|
|
371
407
|
this.worldTick = snap.k; this.worldAt = now; w.tickAt = now;
|
|
408
|
+
this.watchLoop();
|
|
372
409
|
// By this Gate's clock, corrected for what the room's clock is measured to differ by.
|
|
373
410
|
if (Number.isFinite(snap.st)) w.worldAge.push(Math.max(0, now - snap.st + (this.clock?.offsetMs ?? 0)));
|
|
374
411
|
for (const [id, client] of this.clients) if (client.subscribed) this.deliverOne(id, client, snap, client.seat, settings, hz, drop);
|
|
@@ -409,14 +446,17 @@ export class Gate {
|
|
|
409
446
|
}
|
|
410
447
|
async connect(request, server) {
|
|
411
448
|
const link = await this.upstream(new URL(request.url));
|
|
412
|
-
const id = String(++this.sequence);
|
|
413
|
-
|
|
414
|
-
|
|
449
|
+
const id = server.gateState?.id ?? (server.rememberGate ? crypto.randomUUID() : String(++this.sequence));
|
|
450
|
+
if (server.gateState) Object.assign(server, server.gateState);
|
|
451
|
+
server.gateState ??= {id,opened:false};
|
|
452
|
+
server.rememberGate?.(server.gateState);
|
|
453
|
+
this.clients.set(id, server); clearTimeout(this.idle); this.idle = null;
|
|
454
|
+
let opened = server.gateState?.opened === true;
|
|
415
455
|
const deadline = setTimeout(() => {
|
|
416
456
|
this.admissions.delete(id);
|
|
417
457
|
if (!opened && this.clients.delete(id)) { try { server.close(4000, 'no-hello'); } catch { /* The peer already closed; local shutdown still runs. */ } }
|
|
418
458
|
}, 5000);
|
|
419
|
-
deadline?.unref?.(); this.admissions.set(id, deadline);
|
|
459
|
+
deadline?.unref?.(); if (opened) clearTimeout(deadline); else this.admissions.set(id, deadline);
|
|
420
460
|
server.addEventListener('message', event => {
|
|
421
461
|
if (!this.clients.has(id) || typeof event.data !== 'string') return;
|
|
422
462
|
// No legal public frame exceeds the largest checkpoint allowance
|
|
@@ -431,9 +471,11 @@ export class Gate {
|
|
|
431
471
|
if (!opened) {
|
|
432
472
|
opened = true; clearTimeout(deadline); this.admissions.delete(id);
|
|
433
473
|
server.coasts = COASTS.test(event.data);
|
|
474
|
+
server.gateState = {id,opened:true,coasts:server.coasts};
|
|
475
|
+
server.rememberGate?.(server.gateState);
|
|
434
476
|
// Logical creation and the first frame share a batch: admission time at
|
|
435
477
|
// the Table excludes the public handshake and relay transit.
|
|
436
|
-
link.send(['open', id, request.url, request.headers.get('cf-connecting-ip'), request
|
|
478
|
+
link.send(['open', id, request.url, server.address ?? request.headers.get('cf-connecting-ip'), isQa(request)?'homie-house-qa':null]);
|
|
437
479
|
}
|
|
438
480
|
const now = Date.now();
|
|
439
481
|
const said = REPORTED_TICK.exec(event.data);
|
|
@@ -509,13 +551,42 @@ export class Gate {
|
|
|
509
551
|
const gates = [...(this.gateDiagnostics?.values() ?? [])].sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }));
|
|
510
552
|
return Response.json({ toolkit: STUDIO_BUILD, room: url.searchParams.get('room'), ...this.diagnostics, gates, diagnostics: { cached: true, at: this.diagnostics?.st ?? null, ageMs: this.diagnostics ? at - this.diagnostics.st : null } });
|
|
511
553
|
}
|
|
554
|
+
openRelay(url,socket) {
|
|
555
|
+
if(this.readingRelay){this.relayAgain=true;return;}
|
|
556
|
+
const target=new URL(url);target.pathname='/__relay-live';
|
|
557
|
+
const key=`${url.searchParams.get('game')}/${url.searchParams.get('room')}`;
|
|
558
|
+
this.readingRelay=retryRelay(async()=>{
|
|
559
|
+
const stub=this.env.TABLE.get(this.env.TABLE.idFromName(key));
|
|
560
|
+
let response=await stub.fetch(target,{headers:{Upgrade:'websocket','x-homie-relay-epoch':this.relayEpoch}});
|
|
561
|
+
if(!response.webSocket){target.pathname='/__relay-stream';response=await stub.fetch(target,{headers:{'x-homie-relay-epoch':this.relayEpoch}});}
|
|
562
|
+
const epoch=response.headers.get('x-homie-relay-epoch');
|
|
563
|
+
if(this.remoteRelayEpoch!==epoch){this.remoteRelayEpoch=epoch;socket.emit('reset',{});}
|
|
564
|
+
if(response.webSocket){response.webSocket.accept();await socket.attachLive(response.webSocket);}
|
|
565
|
+
else await readRelay(response,socket);
|
|
566
|
+
},()=>this.relaySocket===socket && Boolean(this.link)).finally(()=>{
|
|
567
|
+
this.readingRelay=null;
|
|
568
|
+
if(this.relayAgain&&this.link){this.relayAgain=false;this.openRelay(url,socket);}
|
|
569
|
+
});
|
|
570
|
+
this.ctx.waitUntil(this.readingRelay);
|
|
571
|
+
}
|
|
572
|
+
webSocketMessage(socket, message) { this.watchSockets.message(socket,message); }
|
|
573
|
+
webSocketClose(socket) { return this.watchSockets.close(socket); }
|
|
574
|
+
webSocketError(socket,error) { return this.watchSockets.close(socket,error); }
|
|
512
575
|
async fetch(request) {
|
|
513
576
|
const url = new URL(request.url);
|
|
514
577
|
if (url.pathname === '/__diagnostics') return this.diagnosticsFetch(request, url);
|
|
578
|
+
if (url.pathname === '/__relay-presence') {
|
|
579
|
+
return Response.json([...this.clients].map(([id,client])=>[id,client.lastPing?.()??0]));
|
|
580
|
+
}
|
|
581
|
+
if (url.pathname === '/__relay-wake') {
|
|
582
|
+
if(this.link)this.openRelay(url,this.relaySocket);else await this.upstream(url);
|
|
583
|
+
return new Response(null,{status:204});
|
|
584
|
+
}
|
|
515
585
|
if (url.pathname === '/__multiplex') return acceptMultiplex(request, (req, socket) => this.connect(req, socket));
|
|
516
586
|
const [client, server] = Object.values(new WebSocketPair());
|
|
517
|
-
server.accept();
|
|
518
|
-
|
|
587
|
+
const transport = (url.searchParams.get('relay') === '1' || url.searchParams.get('host') === 'server') && this.ctx.acceptWebSocket ? await this.watchSockets.accept(server, request) : server;
|
|
588
|
+
transport.accept();
|
|
589
|
+
await this.connect(request, transport);
|
|
519
590
|
return new Response(null, { status: 101, webSocket: client });
|
|
520
591
|
}
|
|
521
592
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Keep the transient chat window and its moderation buckets across hibernation.
|
|
2
|
+
* These are room-local state, separate from an owner's optional history archive.
|
|
3
|
+
*/
|
|
4
|
+
export function idleChatSnapshot(table) {
|
|
5
|
+
const room=table.room;
|
|
6
|
+
if(!room)return null;
|
|
7
|
+
return structuredClone({
|
|
8
|
+
at:Date.now(),log:room.chatLog,buckets:room.chatBuckets,address:room.chatAddress,reacts:room.chatReacts,
|
|
9
|
+
stats:Object.fromEntries(Object.entries(room.stats).filter(([key])=>key.startsWith('chat'))),
|
|
10
|
+
sent:table.chatSent,pending:table.chatPend,flushAt:table.chatFlushAt,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export function restoreIdleChat(table,saved) {
|
|
14
|
+
if(!saved||!table.room)return;
|
|
15
|
+
table.room.chatLog=saved.log??[];
|
|
16
|
+
table.room.chatBuckets=saved.buckets??new Map();
|
|
17
|
+
table.room.chatAddress=saved.address??new Map();
|
|
18
|
+
table.room.chatReacts=saved.reacts??[];
|
|
19
|
+
Object.assign(table.room.stats,saved.stats??{});
|
|
20
|
+
table.chatFlushAt=saved.flushAt;
|
|
21
|
+
table.chatSent=saved.sent??table.chatSent;
|
|
22
|
+
table.chatPend=saved.pending??table.chatPend;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const lifetime=15*60_000;
|
|
26
|
+
const previous=new WeakMap();
|
|
27
|
+
const content=snapshot=>encode({...snapshot,at:0});
|
|
28
|
+
const encode=value=>JSON.stringify(value,(_key,item)=>item instanceof Map?{__roomMap:[...item]}:item===-Infinity?{__roomInfinity:-1}:item);
|
|
29
|
+
const decode=value=>JSON.parse(value,(_key,item)=>item?.__roomMap?new Map(item.__roomMap):item?.__roomInfinity===-1?-Infinity:item);
|
|
30
|
+
export async function readIdleChat(storage) {
|
|
31
|
+
if(!storage.sql)return storage.get('watch-chat');
|
|
32
|
+
storage.sql.exec('CREATE TABLE IF NOT EXISTS idle_chat(id INTEGER PRIMARY KEY CHECK(id=1),body TEXT NOT NULL,expires INTEGER NOT NULL)');
|
|
33
|
+
storage.sql.exec('DELETE FROM idle_chat WHERE expires<=?',Date.now());
|
|
34
|
+
const row=storage.sql.exec('SELECT body FROM idle_chat WHERE id=1').toArray()[0];
|
|
35
|
+
const saved=row?decode(row.body):null;
|
|
36
|
+
if(saved)previous.set(storage,content(saved));else previous.delete(storage);
|
|
37
|
+
return saved;
|
|
38
|
+
}
|
|
39
|
+
export async function writeIdleChat(storage,snapshot) {
|
|
40
|
+
const key=content(snapshot);
|
|
41
|
+
if(previous.get(storage)===key)return;
|
|
42
|
+
if(storage.sql) {
|
|
43
|
+
storage.sql.exec('CREATE TABLE IF NOT EXISTS idle_chat(id INTEGER PRIMARY KEY CHECK(id=1),body TEXT NOT NULL,expires INTEGER NOT NULL)');
|
|
44
|
+
storage.sql.exec('INSERT INTO idle_chat VALUES(1,?,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body,expires=excluded.expires',encode(snapshot),snapshot.at+lifetime);
|
|
45
|
+
} else await storage.put('watch-chat',snapshot);
|
|
46
|
+
previous.set(storage,key);
|
|
47
|
+
const alarm=await storage.getAlarm?.();
|
|
48
|
+
if(alarm==null||alarm>snapshot.at+lifetime)await storage.setAlarm?.(snapshot.at+lifetime);
|
|
49
|
+
}
|
|
50
|
+
export async function expireIdleChat(storage) {
|
|
51
|
+
if(!storage.sql)return;
|
|
52
|
+
storage.sql.exec('CREATE TABLE IF NOT EXISTS idle_chat(id INTEGER PRIMARY KEY CHECK(id=1),body TEXT NOT NULL,expires INTEGER NOT NULL)');
|
|
53
|
+
storage.sql.exec('DELETE FROM idle_chat WHERE expires<=?',Date.now());
|
|
54
|
+
const row=storage.sql.exec('SELECT expires FROM idle_chat WHERE id=1').toArray()[0];
|
|
55
|
+
if(row){const alarm=await storage.getAlarm();if(alarm==null||alarm>row.expires)await storage.setAlarm(row.expires);}
|
|
56
|
+
}
|
package/worker/index.mjs
CHANGED
|
@@ -12,6 +12,8 @@ import { appCors, isAppOrigin } from './standalone.mjs';
|
|
|
12
12
|
|
|
13
13
|
import {json,finish} from './responses.mjs';
|
|
14
14
|
|
|
15
|
+
export {useRoutes} from './custom-routes.mjs';
|
|
16
|
+
export {isOwner,hasRole,requireSameOrigin} from './route-auth.mjs';
|
|
15
17
|
export { useFunctions } from './function-registry.mjs';
|
|
16
18
|
export { Gate, Concentrator } from './gate.mjs';
|
|
17
19
|
export { seatCount, SEAT_MAX } from './seats.mjs';
|
package/worker/lazy-objects.mjs
CHANGED
|
@@ -23,6 +23,9 @@ export class Gate {
|
|
|
23
23
|
this.ready=ctx.blockConcurrencyWhile(async()=>{this.gate=new (await import('./gate.mjs')).Gate(ctx,env);});
|
|
24
24
|
}
|
|
25
25
|
async fetch(request) {await this.ready;return this.gate.fetch(request);}
|
|
26
|
+
async webSocketMessage(...args) {await this.ready;return this.gate.webSocketMessage(...args);}
|
|
27
|
+
async webSocketClose(...args) {await this.ready;return this.gate.webSocketClose(...args);}
|
|
28
|
+
async webSocketError(...args) {await this.ready;return this.gate.webSocketError(...args);}
|
|
26
29
|
}
|
|
27
30
|
export class Concentrator extends Gate {}
|
|
28
31
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {counter,today} from './stats.mjs';
|
|
2
|
+
|
|
3
|
+
// One total per bookkeeping shard, never a sum of independent historical peaks.
|
|
4
|
+
// Reports carry their observation time so a delayed request cannot rewind a shard.
|
|
5
|
+
export class LobbyPeak {
|
|
6
|
+
constructor(ctx,env) {this.ctx=ctx;this.env=env;this.chain=Promise.resolve();}
|
|
7
|
+
fetch(request) {
|
|
8
|
+
const task=this.chain.then(()=>this.answer(request));this.chain=task.catch(()=>{});return task;
|
|
9
|
+
}
|
|
10
|
+
async flush() {
|
|
11
|
+
const state=this.state;
|
|
12
|
+
const writes=[state.players>(state.publishedPlayers??0)&&counter(this.env,{metric:'peak',subject:this.subject,n:state.players}),state.room>(state.publishedRoom??0)&&counter(this.env,{metric:'peak-room',subject:this.subject,n:state.room})].filter(Boolean);
|
|
13
|
+
if(writes.length)await this.env.DB.batch(writes);
|
|
14
|
+
// Current totals are volatile. Reusing an old high-water snapshot's shard
|
|
15
|
+
// counts after eviction would incorrectly add players who already left.
|
|
16
|
+
await this.ctx.storage.put('concurrent-peak',{day:state.day,players:state.players,room:state.room,publishedPlayers:state.players,publishedRoom:state.room});
|
|
17
|
+
state.publishedPlayers=state.players;state.publishedRoom=state.room;this.dirty=false;
|
|
18
|
+
}
|
|
19
|
+
schedule() {
|
|
20
|
+
if(this.flushing)return;
|
|
21
|
+
const task=new Promise(resolve=>setTimeout(resolve,1000)).then(()=>{
|
|
22
|
+
const flush=this.chain.then(()=>this.flush());this.chain=flush.catch(()=>{});return flush;
|
|
23
|
+
}).catch(error=>{this.dirty=true;console.warn('Lobby peak write will retry:',error.message);}).finally(()=>{this.flushing=null;});
|
|
24
|
+
this.flushing=task;this.ctx.waitUntil(task);
|
|
25
|
+
}
|
|
26
|
+
async answer(request) {
|
|
27
|
+
if(!this.state)this.state={...await this.ctx.storage.get('concurrent-peak')??{day:today(),players:0,room:0},shards:{}};
|
|
28
|
+
const state=this.state;
|
|
29
|
+
if(state.day!==today()){state.day=today();state.players=0;state.room=0;state.publishedPlayers=0;state.publishedRoom=0;this.dirty=true;}
|
|
30
|
+
if(request.method==='POST') {
|
|
31
|
+
const row=await request.json(),prior=state.shards[row.shard];
|
|
32
|
+
if(!Number.isInteger(row.shard)||row.shard<0||row.shard>=16||!Number.isFinite(row.at)||!Number.isFinite(row.players)||row.players<0)return Response.json({error:'report'},{status:400});
|
|
33
|
+
if(!prior||row.at>prior.at){
|
|
34
|
+
state.shards[row.shard]={players:row.players,at:row.at};
|
|
35
|
+
const concurrent=Object.values(state.shards).filter(r=>r.at>Date.now()-180000).reduce((n,r)=>n+r.players,0);
|
|
36
|
+
const players=Math.max(state.players,concurrent),room=Math.max(state.room,Number(row.room)||0);
|
|
37
|
+
if(players>state.players||room>state.room)this.dirty=true;
|
|
38
|
+
state.players=players;state.room=room;this.subject=new URL(request.url).searchParams.get('game');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if(this.dirty){if(this.ctx.waitUntil)this.schedule();else await this.flush();}
|
|
42
|
+
return Response.json({players:state.players,room:state.room});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {roomServer} from './servers.mjs';
|
|
2
|
+
export const LOBBY_SHARDS=16;
|
|
3
|
+
export const SHARDED_ROOM_BASE=100000000;
|
|
4
|
+
export function lobbyShard(key) {
|
|
5
|
+
let hash=2166136261;for(const c of String(key))hash=Math.imul(hash^c.charCodeAt(0),16777619);
|
|
6
|
+
return (hash>>>0)%LOBBY_SHARDS;
|
|
7
|
+
}
|
|
8
|
+
export function roomShard(room) {
|
|
9
|
+
const server=roomServer(room);
|
|
10
|
+
if(server!=='public')return lobbyShard(server);
|
|
11
|
+
const n=/^pub-(\d+)$/.exec(room)?.[1];
|
|
12
|
+
return n ? Number(n)>=SHARDED_ROOM_BASE?(Number(n)-SHARDED_ROOM_BASE)%LOBBY_SHARDS:0 : lobbyShard(room);
|
|
13
|
+
}
|
|
14
|
+
export function lobbyFor(env,game,identity='') {
|
|
15
|
+
const stub=shard=>env.LOBBY.get(env.LOBBY.idFromName(shard?`${game}/shard/${shard}`:game));
|
|
16
|
+
const send=(shard,url,init)=>{const target=new URL(url);target.searchParams.set('shard',String(shard));return stub(shard).fetch(target.toString(),init);};
|
|
17
|
+
return {async fetch(input,init) {
|
|
18
|
+
const url=new URL(typeof input==='string'?input:input.url??input);
|
|
19
|
+
if(url.pathname==='/join') {
|
|
20
|
+
const server=url.searchParams.get('server')??'public';
|
|
21
|
+
const key=identity||url.searchParams.get('b')||crypto.randomUUID();
|
|
22
|
+
const shard=lobbyShard(server==='public'?key:server);
|
|
23
|
+
url.searchParams.set('reservation',key.slice(0,128));
|
|
24
|
+
if(server==='public'){url.pathname='/match';url.searchParams.set('game',game);return env.LOBBY.get(env.LOBBY.idFromName(`${game}/match/${shard}`)).fetch(url.toString(),init);}
|
|
25
|
+
return send(shard,url,init);
|
|
26
|
+
}
|
|
27
|
+
if(url.pathname==='/report') {
|
|
28
|
+
const body=JSON.parse(init?.body??await input.clone().text());
|
|
29
|
+
return send(roomShard(body.room),url,init??{method:'POST',body:JSON.stringify(body)});
|
|
30
|
+
}
|
|
31
|
+
const rows=await Promise.all(Array.from({length:LOBBY_SHARDS},async(_,shard)=>{
|
|
32
|
+
const response=await send(shard,url,init);if(!response.ok)throw Error('Lobby shard unavailable');return response.json();
|
|
33
|
+
}));
|
|
34
|
+
if(url.pathname==='/busiest')return Response.json(rows.sort((a,b)=>b.players-a.players||String(a.room).localeCompare(String(b.room)))[0]);
|
|
35
|
+
if(url.pathname==='/now'){
|
|
36
|
+
const target=new URL('https://lobby/peak');target.searchParams.set('game',game);
|
|
37
|
+
const response=await env.LOBBY.get(env.LOBBY.idFromName(game+'/peak')).fetch(target);
|
|
38
|
+
if(!response.ok)throw Error('Lobby peak unavailable');
|
|
39
|
+
return Response.json({players:rows.reduce((n,r)=>n+r.players,0),rooms:rows.reduce((n,r)=>n+r.rooms,0),peak:await response.json()});
|
|
40
|
+
}
|
|
41
|
+
return Response.json({rooms:[...new Map(rows.flatMap(r=>r.rooms??[]).map(r=>[r.name??r.room,r])).values()].sort((a,b)=>b.players-a.players)});
|
|
42
|
+
}};
|
|
43
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Row storage keeps a seat update from rewriting every room. The legacy snapshot
|
|
2
|
+
* is imported once in the same SQLite transaction as the migration marker. */
|
|
3
|
+
export function lobbyStore(storage) {
|
|
4
|
+
const sql=storage.sql;
|
|
5
|
+
if(!sql)return {load:()=>storage.get('lobby'),save:value=>storage.put('lobby',value)};
|
|
6
|
+
let previous=new Map(),previousMeta;
|
|
7
|
+
const write=value=>{
|
|
8
|
+
const rooms=new Map(value.rooms.map(room=>[room.name,JSON.stringify(room)]));
|
|
9
|
+
const meta=JSON.stringify({current:value.current,next:value.next,closed:value.closed});
|
|
10
|
+
storage.transactionSync(()=>{
|
|
11
|
+
for(const [name,body] of rooms)if(previous.get(name)!==body)sql.exec('INSERT INTO matchmaking_rooms(name,body) VALUES(?,?) ON CONFLICT(name) DO UPDATE SET body=excluded.body',name,body);
|
|
12
|
+
for(const name of previous.keys())if(!rooms.has(name))sql.exec('DELETE FROM matchmaking_rooms WHERE name=?',name);
|
|
13
|
+
if(meta!==previousMeta)sql.exec("INSERT INTO matchmaking_meta(id,body) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body",meta);
|
|
14
|
+
});
|
|
15
|
+
previous=rooms;previousMeta=meta;
|
|
16
|
+
};
|
|
17
|
+
return {
|
|
18
|
+
async load() {
|
|
19
|
+
sql.exec('CREATE TABLE IF NOT EXISTS matchmaking_rooms(name TEXT PRIMARY KEY,body TEXT NOT NULL)');
|
|
20
|
+
sql.exec('CREATE TABLE IF NOT EXISTS matchmaking_meta(id INTEGER PRIMARY KEY CHECK(id=1),body TEXT NOT NULL)');
|
|
21
|
+
const meta=sql.exec('SELECT body FROM matchmaking_meta WHERE id=1').toArray()[0];
|
|
22
|
+
if(!meta) {
|
|
23
|
+
const legacy=await storage.get('lobby');
|
|
24
|
+
write(legacy??{current:null,next:{public:1},closed:[],rooms:[]});
|
|
25
|
+
// A crash before deletion is harmless: the SQLite marker wins next time.
|
|
26
|
+
await storage.delete('lobby');
|
|
27
|
+
} else {
|
|
28
|
+
previousMeta=meta.body;
|
|
29
|
+
previous=new Map(sql.exec('SELECT name,body FROM matchmaking_rooms').toArray().map(r=>[r.name,r.body]));
|
|
30
|
+
}
|
|
31
|
+
return {...JSON.parse(previousMeta),rooms:[...previous.values()].map(body=>JSON.parse(body))};
|
|
32
|
+
},
|
|
33
|
+
save:write,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const LIVE_ROOMS_QUERY='SELECT game,rooms,updated FROM lobby_summaries ORDER BY game LIMIT 500';
|
|
2
|
+
/** Queue order is not guaranteed. An older snapshot must never replace a newer one. */
|
|
3
|
+
export async function storeLobbySummary(env, message) {
|
|
4
|
+
if(!/^[a-z0-9][a-z0-9-]{0,39}$/.test(message.game)||!Array.isArray(message.rooms)||message.rooms.length>512||!Number.isFinite(message.at))throw new Error('Invalid lobby snapshot');
|
|
5
|
+
const shard=message.shard??0;
|
|
6
|
+
if(!Number.isInteger(shard)||shard<0||shard>=16)throw new Error('Invalid lobby shard');
|
|
7
|
+
await env.DB.prepare(`INSERT INTO lobby_summaries(game,rooms,updated) VALUES(?1,?2,?3)
|
|
8
|
+
ON CONFLICT(game) DO UPDATE SET rooms=excluded.rooms,updated=excluded.updated WHERE excluded.updated>lobby_summaries.updated`)
|
|
9
|
+
.bind(shard?`${message.game}~${shard}`:message.game,JSON.stringify(message.rooms),message.at).run();
|
|
10
|
+
}
|
|
11
|
+
export async function liveRoomSummaries(env) {
|
|
12
|
+
if(!env.BACKGROUND||!env.DB)return null; // Older wrappers still query their Lobby.
|
|
13
|
+
const result=new Map(),since=Date.now()-180000;
|
|
14
|
+
let cursor=null;
|
|
15
|
+
for(;;){
|
|
16
|
+
const query=cursor===null?env.DB.prepare(LIVE_ROOMS_QUERY):env.DB.prepare('SELECT game,rooms,updated FROM lobby_summaries WHERE game>? ORDER BY game LIMIT 500').bind(cursor);
|
|
17
|
+
const rows=(await query.all()).results??[];
|
|
18
|
+
for(const row of rows){
|
|
19
|
+
const game=row.game.split('~')[0],rooms=result.get(game)??[];
|
|
20
|
+
if(row.updated>since)rooms.push(...JSON.parse(row.rooms).filter(room=>room.at>since));
|
|
21
|
+
result.set(game,rooms);
|
|
22
|
+
}
|
|
23
|
+
if(rows.length<500)break;
|
|
24
|
+
cursor=rows.at(-1).game;
|
|
25
|
+
}
|
|
26
|
+
for(const [game,rooms] of result)result.set(game,[...new Map(rooms.map(room=>[room.name,room])).values()]);
|
|
27
|
+
return result;
|
|
28
|
+
}
|