@homie-rocks/studio 0.54.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 (104) hide show
  1. package/CHANGELOG.md +66 -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/background-queue.mjs +31 -0
  8. package/lib/build.mjs +4 -1
  9. package/lib/check.mjs +8 -1
  10. package/lib/cloudflare.mjs +65 -23
  11. package/lib/dev.mjs +6 -8
  12. package/lib/local-database-start.mjs +6 -0
  13. package/lib/mcp-tools.mjs +3 -3
  14. package/lib/port-check.mjs +3 -2
  15. package/lib/read-replication.mjs +26 -0
  16. package/lib/scaffold.mjs +30 -10
  17. package/lib/shop.mjs +7 -0
  18. package/lib/static-assets.mjs +27 -0
  19. package/lib/template-history.json +87 -0
  20. package/lib/worker-build.mjs +16 -4
  21. package/lib/worker-config.mjs +10 -1
  22. package/netplay/NETPLAY.md +5 -1
  23. package/netplay/netplay.ts +24 -9
  24. package/package.json +1 -1
  25. package/rules/core.ts +9 -0
  26. package/rules/host.ts +40 -11
  27. package/rules/view.ts +1 -0
  28. package/tools/TOOLS.md +6 -1
  29. package/worker/analytics.mjs +84 -0
  30. package/worker/app-frame.mjs +1 -1
  31. package/worker/app-graphics.mjs +1 -1
  32. package/worker/app-streams.mjs +3 -3
  33. package/worker/asset-paths.mjs +2 -0
  34. package/worker/background-queue.mjs +31 -0
  35. package/worker/build-id.mjs +1 -1
  36. package/worker/built-metadata.mjs +2 -0
  37. package/worker/catalogue.mjs +18 -0
  38. package/worker/chat-page.mjs +1 -1
  39. package/worker/custom-routes.mjs +16 -0
  40. package/worker/embed.mjs +1 -1
  41. package/worker/event-resources.mjs +23 -11
  42. package/worker/function-registry.mjs +4 -0
  43. package/worker/functions.mjs +7 -5
  44. package/worker/gate-routing.mjs +61 -0
  45. package/worker/gate.mjs +106 -91
  46. package/worker/hosted-registry.mjs +11 -0
  47. package/worker/hosted.mjs +3 -17
  48. package/worker/idle-chat.mjs +56 -0
  49. package/worker/index.mjs +56 -2785
  50. package/worker/lazy-objects.mjs +43 -0
  51. package/worker/lobby-limits.mjs +2 -0
  52. package/worker/lobby-peak.mjs +44 -0
  53. package/worker/lobby-shards.mjs +43 -0
  54. package/worker/lobby-store.mjs +35 -0
  55. package/worker/lobby-summary.mjs +28 -0
  56. package/worker/lobby.mjs +319 -0
  57. package/worker/lounge-store.mjs +2 -2
  58. package/worker/lounge.mjs +3 -3
  59. package/worker/match-router.mjs +74 -0
  60. package/worker/mcp-event-streams.mjs +3 -3
  61. package/worker/mcp-events.mjs +4 -4
  62. package/worker/mcp-room.mjs +2 -1
  63. package/worker/mcp-store.mjs +12 -1
  64. package/worker/mcp-tools.mjs +4 -2
  65. package/worker/mcp.mjs +5 -1
  66. package/worker/office.mjs +18 -21
  67. package/worker/operational-schema.mjs +16 -0
  68. package/worker/page-policy.mjs +29 -0
  69. package/worker/pages.mjs +9 -29
  70. package/worker/parts.mjs +2 -1
  71. package/worker/payment-recovery.mjs +33 -19
  72. package/worker/player-registry.mjs +9 -0
  73. package/worker/players.mjs +8 -7
  74. package/worker/purchase-machine.mjs +5 -3
  75. package/worker/reconcile-guard.mjs +22 -0
  76. package/worker/relay-channel.mjs +119 -0
  77. package/worker/request-db.mjs +36 -10
  78. package/worker/responses.mjs +18 -0
  79. package/worker/room-chat.mjs +217 -0
  80. package/worker/room-controls.mjs +296 -0
  81. package/worker/room-lifecycle.mjs +10 -0
  82. package/worker/room-protocol.mjs +197 -0
  83. package/worker/room-sessions.mjs +65 -0
  84. package/worker/room.mjs +54 -740
  85. package/worker/rooms-cache.mjs +19 -0
  86. package/worker/route-auth.mjs +22 -0
  87. package/worker/routes.mjs +670 -0
  88. package/worker/runtime-support.mjs +447 -0
  89. package/worker/seats.mjs +1 -1
  90. package/worker/selling/index.mjs +1 -0
  91. package/worker/selling/runtime.mjs +7 -5
  92. package/worker/service-run.mjs +1 -1
  93. package/worker/session-codec.mjs +24 -0
  94. package/worker/shop-links.mjs +1 -1
  95. package/worker/shop-page.mjs +1 -0
  96. package/worker/shop.mjs +27 -20
  97. package/worker/stats.mjs +9 -7
  98. package/worker/stream-objects.mjs +18 -0
  99. package/worker/stripe-intake.mjs +79 -0
  100. package/worker/stripe.mjs +3 -0
  101. package/worker/table.mjs +1448 -0
  102. package/worker/tool-registry.mjs +10 -0
  103. package/worker/version.mjs +1 -1
  104. 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';
@@ -27,64 +30,8 @@ function parseFrame(event) {
27
30
  * sockets: clients resume with their existing seat tokens and a fresh keyframe.
28
31
  * Inputs and commands are batched together, never overwritten or reordered.
29
32
  */
30
- /** Players a Gate holds in a room too big for its Table to hold directly.
31
- *
32
- * Two trials on Cloudflare set it. A Gate of 60 dense players that selected and encoded each one's view from
33
- * scratch (0.45.4) could not keep 20 frames a second; the same work now costs a third as much. But twenty to a
34
- * Gate (the first 0.46.2 build) made a room of 600 fifty channels, and the room spent its time on messages, not
35
- * rules: what the room's one thread can bear is counted in messages, and a channel is two a tick. So a Gate is
36
- * as full as it can serve, and a room opens only the Gates its players fill.
37
- * A studio chooses its room's size and nothing else. */
38
- export const GATE_PLAYERS = 50;
39
- /** `per`: players a Gate, for a trial that is finding the right number (`HOMIE_GATE_PLAYERS` on the Worker). A studio never sets it. */
40
- export const gatePlayers = (env) => { const n = Number(env?.HOMIE_GATE_PLAYERS); return Number.isInteger(n) && n >= 4 && n <= 200 ? n : GATE_PLAYERS; };
41
- export function roomLayout(seats, per = GATE_PLAYERS) {
42
- // A room of up to 64 is its Table alone, as it has been since 0.45.0.
43
- const gates = seats <= 64 ? 1 : Math.ceil(seats / per);
44
- return { gates, concentrators: 0 };
45
- }
46
- /** The Gate a key lands on when the room cannot be asked: spread evenly, as 0.45.0 did. */
47
- function hashedGate(key, gates) {
48
- let hash = 2166136261;
49
- for (const c of key || crypto.randomUUID()) hash = Math.imul(hash ^ c.charCodeAt(0), 16777619) >>> 0;
50
- return hash % gates;
51
- }
52
- /**
53
- * Which Gate takes the next player: the lowest one with room. A room of 50 players in a 1,000-seat game is one
54
- * Gate, not twenty with two or three players each, every one of them costing the room a channel. `held` is each
55
- * Gate's players now and `promised` those sent to it who have not arrived yet.
56
- */
57
- export function nextGate(gates, held, promised = new Map(), per = GATE_PLAYERS) {
58
- let least = 0, fewest = Infinity;
59
- for (let i = 0; i < gates; i++) {
60
- const n = (held.get(i) ?? 0) + (promised.get(i) ?? 0);
61
- if (n < per) return i;
62
- if (n < fewest) { fewest = n; least = i; }
63
- }
64
- return least;
65
- }
66
- export function gateFor(env, game, room, seats, key) {
67
- const layout = roomLayout(seats, gatePlayers(env));
68
- // One logical Gate is embedded in the Table for a small room. Same relay,
69
- // admission and delivery protocol; no extra Durable Object or seat clamp.
70
- const table = () => env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
71
- if (layout.gates === 1) return table();
72
- return { async fetch(request) {
73
- const url = new URL(request.url);
74
- // The room says which Gate has room (one short question a join; a join is the room's work anyway). If it
75
- // cannot be asked in time the key's hash chooses, as before.
76
- let index = null;
77
- try {
78
- const asked = await Promise.race([table().fetch(`https://table/__gate?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${seats}&gates=${layout.gates}`), new Promise(done => setTimeout(() => done(null), 2000))]);
79
- const said = asked?.ok ? await asked.json() : null;
80
- if (Number.isInteger(said?.gate) && said.gate >= 0 && said.gate < layout.gates) index = said.gate;
81
- } catch { /* the hash chooses */ }
82
- index ??= hashedGate(key, layout.gates);
83
- url.searchParams.set('gate', String(index));
84
- url.searchParams.set('gates', String(layout.gates));
85
- return env.GATE.get(env.GATE.idFromName(`${game}/${room}/${index}`)).fetch(new Request(url, request));
86
- } };
87
- }
33
+ export {GATE_PLAYERS,gatePlayers,roomLayout,nextGate,gateFor,diagnosticsOf} from './gate-routing.mjs';
34
+ import {diagnosticsOf} from './gate-routing.mjs';
88
35
 
89
36
  export function* expandBatch(rows) {
90
37
  for (const row of rows) {
@@ -94,13 +41,13 @@ export function* expandBatch(rows) {
94
41
  }
95
42
  }
96
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 = () => {} } = {}) {
97
- 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;
98
45
  const pending = new Map();
99
46
  const timing = stageTimings();
100
47
  const stats = { frames: 0, rows: 0, bytes: 0, peakQueueBytes: 0, coalesced: 0, ackMs: 0, maxAckMs: 0, rttMs: 0 };
101
48
  const close = () => { closed = true; queue = []; views.clear(); strings.clear(); pending.clear(); inFlightBytes = 0; bytes = 0; if (timer !== null) clearTimer(timer); timer = null; };
102
- const fail = (reason) => { close(); try { socket.close(1011, reason); } catch {} };
103
- 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); };
49
+ const fail = (reason) => { close(); try { socket.close(1011, reason); } catch { /* The peer already closed; local shutdown still runs. */ } };
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); };
104
51
  const flush = () => {
105
52
  if (timer !== null) clearTimer(timer); timer = null;
106
53
  if (closed) return;
@@ -135,12 +82,14 @@ export function batchLink(socket, { setTimer = setTimeout, clearTimer = clearTim
135
82
  const size = text.length;
136
83
  if (size > maxBytes) { fail('room link frame limit'); return; }
137
84
  if (receipts && inFlightBytes + size > maxBytes) { blockedByBytes = true; return; }
85
+ if(socket.canSend && !socket.canSend(size,text)){blockedByTransport=true;return;}
138
86
  queue = []; views.clear(); strings.clear(); bytes = 0; receipt = 0;
139
87
  if (receipts && hasPayload) { pending.set(++sequence, { at: now(), bytes: size }); inFlightBytes += size; }
140
88
  stats.frames++; stats.rows += rows.length; stats.bytes += size;
141
89
  const sendAt = timing.now();
142
90
  try { lastSentAt = now(); socket.send(text); if (worldSeq) world.sent(worldSeq); timing.record('send', sendAt); onWritable(); } catch { fail('room link send failed'); }
143
91
  };
92
+ socket.addEventListener('drain',()=>{blockedByTransport=false;schedule();});
144
93
  if (receipts) socket.addEventListener('message', event => {
145
94
  if (typeof event.data !== 'string') return;
146
95
  try {
@@ -180,6 +129,7 @@ export function batchLink(socket, { setTimer = setTimeout, clearTimer = clearTim
180
129
  if (inMs !== null && !closed) { if (timer !== null) clearTimer(timer); timer = setTimer(flush, Math.max(0, inMs)); }
181
130
  else schedule();
182
131
  },
132
+ reset() { pending.clear(); sequence=0; receipt=0; inFlightBytes=0; blockedByBytes=false; schedule(); },
183
133
  flush, close, forget: id => views.delete(id), buffered: () => bytes,
184
134
  /** This link's recent round trip, ms: the newest receipt's, or a larger recent one fading out. */
185
135
  rtt: () => stats.rttMs,
@@ -189,12 +139,12 @@ export function batchLink(socket, { setTimer = setTimeout, clearTimer = clearTim
189
139
  }
190
140
 
191
141
  /** Adapt one upstream socket into the same transport the Table accepts locally. */
192
- 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 = () => {} } = {}) {
193
143
  // Eight frames may be unacknowledged: 400 ms of 20 Hz worlds. Past that the newest world replaces an unsent one.
194
144
  // This link's Gate has every world up to `synced`, and holds the players in these seats.
195
145
  let synced = 0; const seats = new Map();
196
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; } });
197
- 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();
198
148
  let chain = Promise.resolve(), closed = false;
199
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() };
200
150
  const pending = new Set(), admissions = [];
@@ -232,12 +182,16 @@ export function multiplexSession(socket, connect, { onMetrics = () => {}, delay
232
182
  if (!Array.isArray(rows)) throw new Error('invalid multiplex batch');
233
183
  heard.frames += 1; heard.rows += rows.length; heard.bytes += event.data.length;
234
184
  for (const [op, id, value, ip, agent] of expandBatch(rows)) {
185
+ if (op === 'resync') { synced=0; continue; }
235
186
  if (op === 'metrics') { onMetrics(id, value); continue; }
236
187
  if (op === 'open') {
237
188
  if (clients.has(id)) throw new Error('duplicate multiplex client');
238
189
  const handlers = new Map();
239
190
  const client = {
240
191
  accept() {},
192
+ resume: resume(id),
193
+ lastPing: () => lastPing(id),
194
+ remember(clientId) { remember(id, clientId, value, ip, agent); },
241
195
  addEventListener(name, fn) { const list = handlers.get(name) ?? []; list.push(fn); handlers.set(name, list); },
242
196
  emit(name, event) { for (const fn of handlers.get(name) ?? []) fn(event); },
243
197
  send(text) { out.send(['data', id, text]); },
@@ -252,7 +206,7 @@ export function multiplexSession(socket, connect, { onMetrics = () => {}, delay
252
206
  client.ready = new Promise((resolve, reject) => {
253
207
  const admit = async () => {
254
208
  if (closed || !clients.has(id)) { resolve(); return; }
255
- 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(); }
256
210
  catch (error) { reject(error); }
257
211
  };
258
212
  admit.cancel = resolve; admissions.push(admit);
@@ -279,11 +233,11 @@ export function multiplexSession(socket, connect, { onMetrics = () => {}, delay
279
233
  }
280
234
  out.consumed(rows);
281
235
  if (admissions.length && !attaching) { if (admissionTimer !== null) clearTimeout(admissionTimer); pump(); }
282
- }).catch(error => { console.log(JSON.stringify({ ev: 'room-link-failed', stage: 'receive', error: String(error?.message ?? error).slice(0,160) })); shutdown(); try { socket.close(1011, 'room link failed'); } catch {} });
236
+ }).catch(error => { console.log(JSON.stringify({ ev: 'room-link-failed', stage: 'receive', error: String(error?.message ?? error).slice(0,160) })); shutdown(); try { socket.close(1011, 'room link failed'); } catch { /* The peer already closed; local shutdown still runs. */ } });
283
237
  });
284
238
  socket.addEventListener('close', shutdown);
285
239
  socket.addEventListener('error', shutdown);
286
- 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]); } };
287
241
  }
288
242
  export function acceptMultiplex(request, connect, options) {
289
243
  const [client, server] = Object.values(new WebSocketPair());
@@ -341,7 +295,16 @@ const COASTS = /^\{"t":"hello".{0,2000}?"coast":1[,}]/;
341
295
  const percentile = (sorted, p) => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : null;
342
296
 
343
297
  export class Gate {
344
- 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
+ }
345
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 }; }
346
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. */
347
310
  watchLoop() {
@@ -350,7 +313,9 @@ export class Gate {
350
313
  this.loopTimer = setInterval(() => {
351
314
  const now = Date.now(), late = Math.max(0, now - due); due = now + 20;
352
315
  this.window.late = Math.max(this.window.late, late); if (late > 20) this.window.busy += 1;
353
- 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; }
354
319
  }, 20);
355
320
  this.loopTimer?.unref?.();
356
321
  }
@@ -371,41 +336,56 @@ export class Gate {
371
336
  // and was the first object to fall behind; sixteen channels of one frame
372
337
  // a tick each way are cheaper for the room than two of hundreds.
373
338
  const target = new URL(url); target.pathname = '/__multiplex';
374
- const response = await this.env.TABLE.get(this.env.TABLE.idFromName(name)).fetch(new Request(target, { headers: { Upgrade: 'websocket' } }));
375
- if (!response.webSocket) throw new Error('room link refused');
376
- 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
+ }
377
351
  // What players send leaves this Gate as one frame at most every 50 ms: at once when the channel has been
378
352
  // quiet, else with whatever arrived since the last. The room's one thread is spent by the message, so a
379
353
  // Gate costs it one a tick each way. (Timing that frame to land just before the room's tick was tried twice
380
354
  // and failed twice on Cloudflare: the round trip to the room varies by more than the window it aims for.)
381
- const link = batchLink(socket, { receipts: true, delay: 50, maxFrames: 8, autoReceipt: false, onAck: (waited, clock) => { if (clock) this.roomClock(clock); } });
382
- 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']);});
383
358
  const lost = (event = {}) => {
384
359
  if (this.link !== link) return;
385
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() }));
386
361
  this.link = null; this.worlds = null; link.close();
387
362
  for (const timer of this.admissions.values()) clearTimeout(timer); this.admissions.clear();
388
- for (const client of this.clients.values()) { try { client.close(1012, 'room link restarted'); } catch {} }
363
+ for (const client of this.clients.values()) { try { client.close(1012, 'room link restarted'); } catch { /* The peer already closed; local shutdown still runs. */ } }
389
364
  this.clients.clear();
390
365
  };
391
- const drop = (id, client) => { this.clients.delete(id); link.send(['close', id]); try { client.close(1011, 'client connection ended'); } catch {} };
366
+ const drop = (id, client) => { this.clients.delete(id); link.send(['close', id]); try { client.close(1011, 'client connection ended'); } catch { /* The peer already closed; local shutdown still runs. */ } };
392
367
  socket.addEventListener('message', event => {
393
368
  if (typeof event.data !== 'string') return;
394
369
  try {
395
370
  const rows = parseFrame(event); let hz = null;
396
371
  for (const row of rows) {
397
- 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; }
398
374
  for (const [op, id, value, reason, settings, rate] of expandBatch([row])) {
399
375
  const client = this.clients.get(id);
400
- 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}); } }
401
377
  else if (op === 'view') { if (client) this.deliverOne(id, client, value, reason, settings, rate, drop); }
402
378
  else if (op === 'data') { try { client?.send(value); } catch { if (client) drop(id, client); } }
403
- else if (op === 'close') { this.clients.delete(id); try { client?.close(value, reason); } catch {} }
379
+ else if (op === 'close') { this.clients.delete(id); try { client?.close(value, reason); } catch { /* The peer already closed; local shutdown still runs. */ } }
404
380
  }
405
381
  }
406
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
+ }
407
387
  if (hz && Date.now() - this.metricsAt >= 1000) this.publishMetrics(url, link);
408
- } catch (error) { lost({ error, reason: 'invalid room frame' }); try { socket.close(1011, 'room link failed'); } catch {} }
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. */ } }
409
389
  });
410
390
  socket.addEventListener('close', lost); socket.addEventListener('error', lost);
411
391
  // A Gate nobody is on gives its channel back after ten seconds: an open channel keeps the room and this Gate in
@@ -415,7 +395,7 @@ export class Gate {
415
395
  this.link = null; this.worlds = null; link.close();
416
396
  try { socket.close(1000, 'no players'); } catch { /* gone */ }
417
397
  };
418
- this.link = link; return link;
398
+ this.link = link; if(socket===this.relaySocket)this.openRelay(url,socket); return link;
419
399
  })();
420
400
  try { return await this.opening; } finally { this.opening = null; }
421
401
  }
@@ -425,6 +405,7 @@ export class Gate {
425
405
  if (w.tickFrom === null) { w.tickFrom = this.worldTick ?? snap.k; w.tickFromAt = this.worldAt ?? now; }
426
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; }
427
407
  this.worldTick = snap.k; this.worldAt = now; w.tickAt = now;
408
+ this.watchLoop();
428
409
  // By this Gate's clock, corrected for what the room's clock is measured to differ by.
429
410
  if (Number.isFinite(snap.st)) w.worldAge.push(Math.max(0, now - snap.st + (this.clock?.offsetMs ?? 0)));
430
411
  for (const [id, client] of this.clients) if (client.subscribed) this.deliverOne(id, client, snap, client.seat, settings, hz, drop);
@@ -465,14 +446,17 @@ export class Gate {
465
446
  }
466
447
  async connect(request, server) {
467
448
  const link = await this.upstream(new URL(request.url));
468
- const id = String(++this.sequence);
469
- this.clients.set(id, server); clearTimeout(this.idle); this.idle = null; this.watchLoop();
470
- 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;
471
455
  const deadline = setTimeout(() => {
472
456
  this.admissions.delete(id);
473
- if (!opened && this.clients.delete(id)) { try { server.close(4000, 'no-hello'); } catch {} }
457
+ if (!opened && this.clients.delete(id)) { try { server.close(4000, 'no-hello'); } catch { /* The peer already closed; local shutdown still runs. */ } }
474
458
  }, 5000);
475
- deadline?.unref?.(); this.admissions.set(id, deadline);
459
+ deadline?.unref?.(); if (opened) clearTimeout(deadline); else this.admissions.set(id, deadline);
476
460
  server.addEventListener('message', event => {
477
461
  if (!this.clients.has(id) || typeof event.data !== 'string') return;
478
462
  // No legal public frame exceeds the largest checkpoint allowance
@@ -481,15 +465,17 @@ export class Gate {
481
465
  if (event.data.length > 128 * 1024) {
482
466
  clearTimeout(deadline); this.admissions.delete(id); this.clients.delete(id);
483
467
  if (opened) link.send(['close', id]);
484
- try { server.close(1009, 'message too large'); } catch {}
468
+ try { server.close(1009, 'message too large'); } catch { /* The peer already closed; local shutdown still runs. */ }
485
469
  return;
486
470
  }
487
471
  if (!opened) {
488
472
  opened = true; clearTimeout(deadline); this.admissions.delete(id);
489
473
  server.coasts = COASTS.test(event.data);
474
+ server.gateState = {id,opened:true,coasts:server.coasts};
475
+ server.rememberGate?.(server.gateState);
490
476
  // Logical creation and the first frame share a batch: admission time at
491
477
  // the Table excludes the public handshake and relay transit.
492
- 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]);
493
479
  }
494
480
  const now = Date.now();
495
481
  const said = REPORTED_TICK.exec(event.data);
@@ -545,7 +531,7 @@ export class Gate {
545
531
  this.metricsPending = true;
546
532
  const work = diagnosticsOf(this.env, url.searchParams.get('game'), url.searchParams.get('room')).fetch('https://diagnostics/__diagnostics', {
547
533
  method: 'POST', body: JSON.stringify({ kind: 'gate', id: `gate/${url.searchParams.get('gate')}`, at: now, ...facts }),
548
- }).then(r => r.body?.cancel?.()).catch(() => {}).finally(() => { this.metricsPending = false; });
534
+ }).then(r => r.body?.cancel?.()).catch(error => console.warn("Gate diagnostic publication failed:",error.message)).finally(() => { this.metricsPending = false; });
549
535
  this.ctx.waitUntil?.(work);
550
536
  }
551
537
  /** The room's diagnostics, kept by an object that is neither the room nor a
@@ -565,17 +551,46 @@ export class Gate {
565
551
  const gates = [...(this.gateDiagnostics?.values() ?? [])].sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }));
566
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 } });
567
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); }
568
575
  async fetch(request) {
569
576
  const url = new URL(request.url);
570
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
+ }
571
585
  if (url.pathname === '/__multiplex') return acceptMultiplex(request, (req, socket) => this.connect(req, socket));
572
586
  const [client, server] = Object.values(new WebSocketPair());
573
- server.accept();
574
- 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);
575
590
  return new Response(null, { status: 101, webSocket: client });
576
591
  }
577
592
  }
578
593
  /** The object that keeps one room's diagnostics (a Gate by class, never one with players). */
579
- export function diagnosticsOf(env, game, room) { return env.GATE.get(env.GATE.idFromName(`${game}/${room}/diagnostics`)); }
594
+
580
595
  /** Kept so a studio deployed with 0.45.0's Concentrator binding still loads; nothing opens one. */
581
596
  export class Concentrator extends Gate {}
@@ -0,0 +1,11 @@
1
+ /** Declarations stay cheap to inspect on HTTP routes; the host engine loads in Table. */
2
+ export const games=new Map();
3
+ export const compiled=new Map();
4
+ export function hostRules(table) {
5
+ games.clear();compiled.clear();
6
+ if(!table||typeof table!=='object')return 0;
7
+ for(const [id,entry] of Object.entries(table))if(entry&&typeof entry==='object'&&entry.rules)games.set(id,entry);
8
+ return games.size;
9
+ }
10
+ export const hostedGame=id=>games.has(id);
11
+ export const hostedBuild=id=>games.get(id);
package/worker/hosted.mjs CHANGED
@@ -16,23 +16,9 @@
16
16
  import { createHost } from '../rules/host.ts';
17
17
  import { compileMap, compileRules } from '../rules/rules.ts';
18
18
 
19
- /** game id → { rules, tune, map, settings, seats, build } as the build wrote it, and what compiling each gave. */
20
- const games = new Map();
21
- const compiled = new Map();
22
- const readiness = new WeakMap();
23
-
24
- /** The table of this studio's server-hosted games (the build's site/src/rules/index.mjs). Called once, at load. */
25
- export function hostRules(table) {
26
- games.clear();
27
- compiled.clear();
28
- if (!table || typeof table !== 'object') return 0;
29
- for (const [id, entry] of Object.entries(table)) if (entry && typeof entry === 'object' && entry.rules) games.set(id, entry);
30
- return games.size;
31
- }
32
-
33
- /** Whether this Worker holds rules for a game. */
34
- export const hostedGame = (id) => games.has(id);
35
- export const hostedBuild = (id) => games.get(id);
19
+ import {games,compiled} from './hosted-registry.mjs';
20
+ export {hostRules,hostedGame,hostedBuild} from './hosted-registry.mjs';
21
+ const readiness=new WeakMap();
36
22
 
37
23
  /** Optional engines load at room start. Ordinary games keep their synchronous startup path. */
38
24
  export function prepareHost(id) {
@@ -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
+ }