@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +2 -0
  3. package/bin/homie-studio.mjs +1 -1
  4. package/docs/custom-routes.md +72 -0
  5. package/guides/app/SKILL.md +7 -0
  6. package/guides/playtest/scripts/playtest.mjs +7 -5
  7. package/lib/check.mjs +8 -1
  8. package/lib/cloudflare.mjs +49 -22
  9. package/lib/dev.mjs +6 -8
  10. package/lib/local-database-start.mjs +6 -0
  11. package/lib/mcp-tools.mjs +3 -3
  12. package/lib/port-check.mjs +3 -2
  13. package/lib/scaffold.mjs +2 -0
  14. package/lib/template-history.json +46 -1
  15. package/lib/worker-build.mjs +4 -1
  16. package/lib/worker-config.mjs +7 -0
  17. package/netplay/NETPLAY.md +5 -1
  18. package/netplay/netplay.ts +24 -9
  19. package/package.json +1 -1
  20. package/rules/core.ts +9 -0
  21. package/rules/host.ts +40 -11
  22. package/rules/view.ts +1 -0
  23. package/tools/TOOLS.md +6 -1
  24. package/worker/background-queue.mjs +1 -0
  25. package/worker/build-id.mjs +1 -1
  26. package/worker/custom-routes.mjs +16 -0
  27. package/worker/gate.mjs +94 -23
  28. package/worker/idle-chat.mjs +56 -0
  29. package/worker/index.mjs +2 -0
  30. package/worker/lazy-objects.mjs +3 -0
  31. package/worker/lobby-peak.mjs +44 -0
  32. package/worker/lobby-shards.mjs +43 -0
  33. package/worker/lobby-store.mjs +35 -0
  34. package/worker/lobby-summary.mjs +28 -0
  35. package/worker/lobby.mjs +86 -18
  36. package/worker/lounge-store.mjs +2 -2
  37. package/worker/match-router.mjs +74 -0
  38. package/worker/mcp-room.mjs +2 -1
  39. package/worker/mcp-tools.mjs +4 -2
  40. package/worker/office.mjs +2 -1
  41. package/worker/operational-schema.mjs +2 -0
  42. package/worker/pages.mjs +1 -1
  43. package/worker/relay-channel.mjs +119 -0
  44. package/worker/request-db.mjs +4 -3
  45. package/worker/room-chat.mjs +2 -1
  46. package/worker/room-sessions.mjs +65 -0
  47. package/worker/room.mjs +16 -9
  48. package/worker/route-auth.mjs +22 -0
  49. package/worker/routes.mjs +7 -4
  50. package/worker/runtime-support.mjs +5 -3
  51. package/worker/session-codec.mjs +24 -0
  52. package/worker/stats.mjs +2 -1
  53. package/worker/table.mjs +277 -41
  54. package/worker/version.mjs +1 -1
  55. 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: true, delay, maxFrames: 8, autoReceipt: false, world, onWritable: () => scheduleAdmission() }), clients = new Map();
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
- if (!this.link || !this.clients.size) { clearInterval(this.loopTimer); this.loopTimer = null; }
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
- const response = await this.env.TABLE.get(this.env.TABLE.idFromName(name)).fetch(new Request(target, { headers: { Upgrade: 'websocket' } }));
319
- if (!response.webSocket) throw new Error('room link refused');
320
- const socket = response.webSocket; socket.accept();
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: true, delay: 50, maxFrames: 8, autoReceipt: false, onAck: (waited, clock) => { if (clock) this.roomClock(clock); } });
326
- const sink = worldSink();
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 (row[0] === 'world') { hz = row[4]; const snap = row[2].w ? sink.decode(row[2], row[5]) : row[2]; this.deliver(snap, row[3], row[4], drop); continue; }
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
- this.clients.set(id, server); clearTimeout(this.idle); this.idle = null; this.watchLoop();
414
- let opened = false;
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.headers.get('user-agent')]);
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
- await this.connect(request, server);
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';
@@ -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
+ }