@homie-rocks/studio 0.46.0 → 0.46.2

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/worker/gate.mjs CHANGED
@@ -1,22 +1,83 @@
1
+ import { STUDIO_BUILD } from './build-id.mjs';
2
+ import { stageTimings } from '../rules/timing.mjs';
3
+ import { crowdView, crowdWorlds, scheduledView, snapshotEncoder, snapshotText } from '../rules/interest.mjs';
4
+ import { worldSink, worldSource } from './world-wire.mjs';
5
+
6
+ const parsedFrames = new WeakMap();
7
+ const sharedSnapshotText = new WeakMap();
8
+ function batchText(rows) {
9
+ return '[' + rows.map(row => {
10
+ // A world's block of numbers is base64, which needs no escaping: it is joined on, not walked 50 times a tick.
11
+ if (row[0] === 'world' && typeof row[5] === 'string') return JSON.stringify(row.slice(0, 5)).slice(0, -1) + ',"' + row[5] + '"]';
12
+ if (row[0] !== 'views' && row[0] !== 'world') return JSON.stringify(row);
13
+ // All downstream links share this immutable state. Serialize it once in
14
+ // this isolate, rather than once for every Gate fed by a Concentrator.
15
+ let state = sharedSnapshotText.get(row[2]);
16
+ if (state === undefined) { state = JSON.stringify(row[2]); sharedSnapshotText.set(row[2], state); }
17
+ return '[' + JSON.stringify(row[0]) + ',' + JSON.stringify(row[1]) + ',' + state + ',' + JSON.stringify([row[3], row[4]]).slice(1, -1) + ']';
18
+ }).join(',') + ']';
19
+ }
20
+ function parseFrame(event) {
21
+ let rows = parsedFrames.get(event);
22
+ if (!rows) { rows = JSON.parse(event.data); parsedFrames.set(event, rows); }
23
+ return rows;
24
+ }
1
25
  /** Trusted, ordered multiplex links. Public authentication stays at the Worker;
2
26
  * only bindings can open these endpoints. A lost link closes its downstream
3
27
  * sockets: clients resume with their existing seat tokens and a fresh keyframe.
4
28
  * Inputs and commands are batched together, never overwritten or reordered.
5
29
  */
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;
6
39
  export function roomLayout(seats) {
7
- const gates = Math.ceil(seats / 64);
8
- return { gates, concentrators: gates > 8 ? Math.ceil(gates / 8) : 0 };
40
+ // A room of up to 64 is its Table alone, as it has been since 0.45.0.
41
+ const gates = seats <= 64 ? 1 : Math.ceil(seats / GATE_PLAYERS);
42
+ return { gates, concentrators: 0 };
43
+ }
44
+ /** The Gate a key lands on when the room cannot be asked: spread evenly, as 0.45.0 did. */
45
+ function hashedGate(key, gates) {
46
+ let hash = 2166136261;
47
+ for (const c of key || crypto.randomUUID()) hash = Math.imul(hash ^ c.charCodeAt(0), 16777619) >>> 0;
48
+ return hash % gates;
49
+ }
50
+ /**
51
+ * Which Gate takes the next player: the lowest one with room. A room of 50 players in a 1,000-seat game is one
52
+ * Gate, not twenty with two or three players each, every one of them costing the room a channel. `held` is each
53
+ * Gate's players now and `promised` those sent to it who have not arrived yet.
54
+ */
55
+ export function nextGate(gates, held, promised = new Map()) {
56
+ let least = 0, fewest = Infinity;
57
+ for (let i = 0; i < gates; i++) {
58
+ const n = (held.get(i) ?? 0) + (promised.get(i) ?? 0);
59
+ if (n < GATE_PLAYERS) return i;
60
+ if (n < fewest) { fewest = n; least = i; }
61
+ }
62
+ return least;
9
63
  }
10
64
  export function gateFor(env, game, room, seats, key) {
11
65
  const layout = roomLayout(seats);
12
66
  // One logical Gate is embedded in the Table for a small room. Same relay,
13
67
  // admission and delivery protocol; no extra Durable Object or seat clamp.
14
- if (layout.gates === 1) return env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
15
- let hash = 2166136261;
16
- for (const c of key || crypto.randomUUID()) hash = Math.imul(hash ^ c.charCodeAt(0), 16777619) >>> 0;
17
- const index = hash % layout.gates;
18
- return { fetch(request) {
68
+ const table = () => env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
69
+ if (layout.gates === 1) return table();
70
+ return { async fetch(request) {
19
71
  const url = new URL(request.url);
72
+ // The room says which Gate has room (one short question a join; a join is the room's work anyway). If it
73
+ // cannot be asked in time the key's hash chooses, as before.
74
+ let index = null;
75
+ try {
76
+ 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))]);
77
+ const said = asked?.ok ? await asked.json() : null;
78
+ if (Number.isInteger(said?.gate) && said.gate >= 0 && said.gate < layout.gates) index = said.gate;
79
+ } catch { /* the hash chooses */ }
80
+ index ??= hashedGate(key, layout.gates);
20
81
  url.searchParams.set('gate', String(index));
21
82
  url.searchParams.set('gates', String(layout.gates));
22
83
  return env.GATE.get(env.GATE.idFromName(`${game}/${room}/${index}`)).fetch(new Request(url, request));
@@ -25,45 +86,148 @@ export function gateFor(env, game, room, seats, key) {
25
86
 
26
87
  export function* expandBatch(rows) {
27
88
  for (const row of rows) {
28
- if (row[0] === 'fan') { for (const id of row[1]) yield ['data', id, row[2]]; }
89
+ if (row[0] === 'views') { for (const [id, seat] of row[1]) yield ['view', id, row[2], seat, row[3], row[4]]; }
90
+ else if (row[0] === 'fan') { for (const id of row[1]) yield row.length > 3 ? ['data', id, row[2], row[3]] : ['data', id, row[2]]; }
29
91
  else yield row;
30
92
  }
31
93
  }
32
- export function batchLink(socket, { setTimer = setTimeout, clearTimer = clearTimeout, delay = 5 } = {}) {
33
- let queue = [], timer = null, closed = false;
94
+ 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 = () => {} } = {}) {
95
+ 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;
96
+ const pending = new Map();
97
+ const timing = stageTimings();
98
+ const stats = { frames: 0, rows: 0, bytes: 0, peakQueueBytes: 0, coalesced: 0, ackMs: 0, maxAckMs: 0, rttMs: 0 };
99
+ const close = () => { closed = true; queue = []; views.clear(); strings.clear(); pending.clear(); inFlightBytes = 0; bytes = 0; if (timer !== null) clearTimer(timer); timer = null; };
100
+ const fail = (reason) => { close(); try { socket.close(1011, reason); } catch {} };
101
+ 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); };
34
102
  const flush = () => {
35
- timer = null;
36
- if (closed || !queue.length) return;
37
- const rows = []; let last = null;
103
+ if (timer !== null) clearTimer(timer); timer = null;
104
+ if (closed) return;
105
+ if (pending.size >= maxFrames || blockedByBytes) {
106
+ if (receipt) { try { socket.send(JSON.stringify([['ack', receipt, now() - receiptAt]])); receipt = 0; } catch { fail('room link send failed'); } }
107
+ return;
108
+ }
109
+ if (!queue.length && !views.size && !receipt) return;
110
+ const rows = []; let last = null, worldSeq = 0;
111
+ const hasPayload = queue.length || views.size;
112
+ // The receipt waits for this link's next scheduled frame; say how long,
113
+ // so the sender's receipt latency measures transit, not the cadence.
114
+ if (receipt) rows.push(['ack', receipt, now() - receiptAt, ...(stamp ? [[stamp[0], stamp[1], now()]] : [])]);
38
115
  for (const row of queue) {
39
- if (row[0] === 'data' && last && (last[0] === 'data' || last[0] === 'fan') && last[2] === row[2]) {
116
+ if (row[0] === 'data' && last && (last[0] === 'data' || last[0] === 'fan') && last[2] === row[2] && last[3] === row[3]) {
40
117
  if (last[0] === 'data') { last[0] = 'fan'; last[1] = [last[1]]; }
41
118
  last[1].push(row[1]);
42
119
  } else { last = row.slice(); rows.push(last); }
43
120
  }
44
- queue = [];
45
- try { socket.send(JSON.stringify(rows)); } catch { close(); try { socket.close(1011, 'room link send failed'); } catch {} }
121
+ const groups = new Map();
122
+ for (const row of views.values()) {
123
+ // The room's side says how a world travels (worker/world-wire.mjs): a block of numbers and what changed.
124
+ if (row[0] === 'world') { const sent = world?.(row); if (sent) { rows.push(sent.row); worldSeq = sent.seq; } else rows.push(row); continue; }
125
+ let group = groups.get(row[2]);
126
+ if (!group) { group = ['views', [], row[2], row[4], row[5]]; groups.set(row[2], group); rows.push(group); }
127
+ group[1].push([row[1], row[3]]);
128
+ }
129
+ if (receipts && hasPayload) rows.push(['receipt', sequence + 1, now()]);
130
+ const encodeAt = timing.now();
131
+ const text = batchText(rows);
132
+ timing.record('encode', encodeAt);
133
+ const size = text.length;
134
+ if (size > maxBytes) { fail('room link frame limit'); return; }
135
+ if (receipts && inFlightBytes + size > maxBytes) { blockedByBytes = true; return; }
136
+ queue = []; views.clear(); strings.clear(); bytes = 0; receipt = 0;
137
+ if (receipts && hasPayload) { pending.set(++sequence, { at: now(), bytes: size }); inFlightBytes += size; }
138
+ stats.frames++; stats.rows += rows.length; stats.bytes += size;
139
+ const sendAt = timing.now();
140
+ try { lastSentAt = now(); socket.send(text); if (worldSeq) world.sent(worldSeq); timing.record('send', sendAt); onWritable(); } catch { fail('room link send failed'); }
141
+ };
142
+ if (receipts) socket.addEventListener('message', event => {
143
+ if (typeof event.data !== 'string') return;
144
+ try {
145
+ const rows = parseFrame(event);
146
+ if (!Array.isArray(rows)) return;
147
+ for (const row of rows) {
148
+ if (row[0] === 'ack' && pending.has(row[1])) {
149
+ const frame = pending.get(row[1]);
150
+ stats.ackMs = Math.max(0, now() - frame.at - (Number(row[2]) || 0)); stats.rttMs = Math.max(stats.ackMs, stats.rttMs * 0.95); onAck(Number(row[2]) || 0, Array.isArray(row[3]) ? row[3] : null); stats.maxAckMs = Math.max(stats.maxAckMs, stats.ackMs);
151
+ for (const [seq, held] of pending) if (seq <= row[1]) { pending.delete(seq); inFlightBytes -= held.bytes; } blockedByBytes = false; schedule(); onWritable();
152
+ }
153
+ if (autoReceipt && row[0] === 'receipt') socket.send(JSON.stringify([['ack',row[1]]]));
154
+ }
155
+ } catch { /* the session reports malformed frames */ }
156
+ });
157
+ return {
158
+ send(row) {
159
+ if (closed) return;
160
+ if (row[0] === 'close') views.delete(row[1]);
161
+ // Snapshots are absolute here; Gate encoders run only when they deliver.
162
+ // Replace unsent state, never an input, command, admission or departure.
163
+ if (row[0] === 'view' || row[0] === 'world') {
164
+ if (views.has(row[1])) stats.coalesced++;
165
+ views.set(row[1], row); schedule(); return;
166
+ }
167
+ // Fan-out payload strings are shared in memory and on the wire.
168
+ const size = row[0] === 'data' ? 48 + (strings.has(row[2]) ? 0 : row[2].length) : JSON.stringify(row).length;
169
+ if (row[0] === 'data') strings.add(row[2]);
170
+ if (bytes + size > maxBytes) { fail('room link queue limit'); return; }
171
+ queue.push(row); bytes += size; stats.peakQueueBytes = Math.max(bytes, stats.peakQueueBytes);
172
+ schedule();
173
+ },
174
+ /** Rows were handled: owe their receipt on this link's next frame. `inMs`
175
+ * aligns that frame (a Gate sends its tick's inputs just before the next tick). */
176
+ consumed(rows, inMs = null) {
177
+ for (const row of rows) if (row[0] === 'receipt' && row[1] > receipt) { if (!receipt) receiptAt = now(); receipt = row[1]; stamp = Number.isFinite(row[2]) ? [row[2], now()] : null; }
178
+ if (inMs !== null && !closed) { if (timer !== null) clearTimer(timer); timer = setTimer(flush, Math.max(0, inMs)); }
179
+ else schedule();
180
+ },
181
+ flush, close, forget: id => views.delete(id), buffered: () => bytes,
182
+ /** This link's recent round trip, ms: the newest receipt's, or a larger recent one fading out. */
183
+ rtt: () => stats.rttMs,
184
+ facts: () => ({ ...stats, timing: timing.facts(), queueRows: queue.length, pendingViews: views.size, queueBytes: bytes, inFlight: pending.size, inFlightBytes }),
185
+ disconnect() { close(); socket.close(1012, 'room link restarted'); }
46
186
  };
47
- const close = () => { closed = true; queue = []; if (timer !== null) clearTimer(timer); timer = null; };
48
- return { send(row) { if (closed) return; queue.push(row); if (queue.length >= 128) { if (timer !== null) clearTimer(timer); flush(); } else if (timer === null) timer = setTimer(flush, delay); }, flush, close, disconnect() { close(); socket.close(1012, 'room link restarted'); } };
49
187
  }
50
188
 
51
189
  /** Adapt one upstream socket into the same transport the Table accepts locally. */
52
- export function multiplexSession(socket, connect) {
53
- const out = batchLink(socket), clients = new Map();
190
+ export function multiplexSession(socket, connect, { onMetrics = () => {}, delay = 60, source = worldSource(), onCount = () => {} } = {}) {
191
+ // Eight frames may be unacknowledged: 400 ms of 20 Hz worlds. Past that the newest world replaces an unsent one.
192
+ // This link's Gate has every world up to `synced`, and holds the players in these seats.
193
+ let synced = 0; const seats = new Map();
194
+ 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; } });
195
+ const out = batchLink(socket, { receipts: true, delay, maxFrames: 8, autoReceipt: false, world, onWritable: () => scheduleAdmission() }), clients = new Map();
54
196
  let chain = Promise.resolve(), closed = false;
197
+ 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() };
198
+ const pending = new Set(), admissions = [];
199
+ let attaching = 0, admissionTimer = null;
200
+ const pump = () => {
201
+ admissionTimer = null;
202
+ if (closed) return;
203
+ // A welcome contains the peer list. Bound simultaneous welcome creation,
204
+ // leaving space for existing players' reliable traffic while a link drains.
205
+ while (admissions.length && attaching < 8 && out.buffered() < 512 * 1024) {
206
+ const admit = admissions.shift(); attaching++;
207
+ Promise.resolve().then(admit).finally(() => { attaching--; scheduleAdmission(); });
208
+ }
209
+ if (admissions.length) scheduleAdmission();
210
+ };
211
+ const scheduleAdmission = () => {
212
+ if (!closed && admissions.length && attaching < 8 && out.buffered() < 512 * 1024 && admissionTimer === null) admissionTimer = setTimeout(pump, 1);
213
+ };
214
+ const transportFacts = () => ({ ...out.facts(), admissions: admissions.length, attaching });
55
215
  const shutdown = () => {
56
216
  if (closed) return;
57
217
  closed = true; out.close();
218
+ if (admissionTimer !== null) clearTimeout(admissionTimer);
219
+ for (const admit of admissions.splice(0)) admit.cancel();
58
220
  for (const client of clients.values()) client.emit('close', {});
59
- clients.clear();
221
+ clients.clear(); seats.clear(); onCount(0);
60
222
  };
61
223
  socket.addEventListener('message', event => {
224
+ if (typeof event.data !== 'string') return;
62
225
  chain = chain.then(async () => {
63
226
  if (closed) return;
64
- const rows = JSON.parse(event.data);
227
+ const rows = parseFrame(event);
65
228
  if (!Array.isArray(rows)) throw new Error('invalid multiplex batch');
66
229
  for (const [op, id, value, ip, agent] of expandBatch(rows)) {
230
+ if (op === 'metrics') { onMetrics(id, value); continue; }
67
231
  if (op === 'open') {
68
232
  if (clients.has(id)) throw new Error('duplicate multiplex client');
69
233
  const handlers = new Map();
@@ -72,80 +236,299 @@ export function multiplexSession(socket, connect) {
72
236
  addEventListener(name, fn) { const list = handlers.get(name) ?? []; list.push(fn); handlers.set(name, list); },
73
237
  emit(name, event) { for (const fn of handlers.get(name) ?? []) fn(event); },
74
238
  send(text) { out.send(['data', id, text]); },
75
- close(code, reason) { out.send(['close', id, code, reason]); clients.delete(id); client.emit('close', {}); },
239
+ snapshot(snap, seat, settings, hz) { out.send(['view', id, snap, seat, settings, hz]); },
240
+ snapshotGroup: group,
241
+ subscribe(seat) { group.subscribe(id, seat); },
242
+ buffered: () => out.buffered(),
243
+ transportFacts,
244
+ close(code, reason) { out.send(['close', id, code, reason]); clients.delete(id); seats.delete(id); onCount(clients.size); client.emit('close', {}); },
76
245
  };
77
- clients.set(id, client);
78
- await connect(new Request(value, { headers: { Upgrade: 'websocket', ...(ip ? { 'cf-connecting-ip': ip } : {}), ...(agent ? { 'user-agent': agent } : {}) } }), client);
79
- } else if (op === 'data') clients.get(id)?.emit('message', { data: value });
80
- else if (op === 'close') { clients.get(id)?.emit('close', {}); clients.delete(id); }
246
+ clients.set(id, client); onCount(clients.size);
247
+ client.ready = new Promise((resolve, reject) => {
248
+ const admit = async () => {
249
+ if (closed || !clients.has(id)) { resolve(); return; }
250
+ try { await connect(new Request(value, { headers: { Upgrade: 'websocket', ...(ip ? { 'cf-connecting-ip': ip } : {}), ...(agent ? { 'user-agent': agent } : {}) } }), client); client.attached = true; resolve(); }
251
+ catch (error) { reject(error); }
252
+ };
253
+ admit.cancel = resolve; admissions.push(admit);
254
+ });
255
+ scheduleAdmission();
256
+ const ready = client.ready; pending.add(ready);
257
+ ready.then(() => { pending.delete(ready); if (closed || !clients.has(id)) client.emit('close', {}); }, () => { pending.delete(ready); client.close(1011, 'admission failed'); });
258
+ } else if (op === 'data' || op === 'close') {
259
+ const client = clients.get(id);
260
+ if (client) {
261
+ client.queued = (client.queued ?? 0) + 1;
262
+ if (client.queued > 256) { client.close(1013, 'client queue limit'); continue; }
263
+ const deliver = () => {
264
+ client.queued--;
265
+ if (closed || !clients.has(id)) return;
266
+ if (op === 'data') client.emit('message', { data: value, receivedAt: ip });
267
+ else { out.forget(id); client.emit('close', {}); clients.delete(id); seats.delete(id); onCount(clients.size); }
268
+ };
269
+ if (client.attached && client.queued === 1) { deliver(); continue; }
270
+ const task = client.ready = client.ready.then(deliver);
271
+ pending.add(task); task.then(() => pending.delete(task), () => { pending.delete(task); client.close(1011, 'client message failed'); });
272
+ }
273
+ }
81
274
  }
82
- }).catch(() => { shutdown(); try { socket.close(1011, 'room link failed'); } catch {} });
275
+ out.consumed(rows);
276
+ if (admissions.length && !attaching) { if (admissionTimer !== null) clearTimeout(admissionTimer); pump(); }
277
+ }).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 {} });
83
278
  });
84
279
  socket.addEventListener('close', shutdown);
85
280
  socket.addEventListener('error', shutdown);
86
- return { shutdown, settled: () => chain };
281
+ return { group, shutdown, settled: async () => { await chain; await Promise.allSettled([...pending]); } };
87
282
  }
88
- export function acceptMultiplex(request, connect) {
283
+ export function acceptMultiplex(request, connect, options) {
89
284
  const [client, server] = Object.values(new WebSocketPair());
90
- server.accept(); multiplexSession(server, connect);
285
+ server.accept(); multiplexSession(server, connect, options);
91
286
  return new Response(null, { status: 101, webSocket: client });
92
287
  }
93
288
 
289
+ /** How often one player is sent the world, from what that player says it has.
290
+ *
291
+ * A socket to a slow player has no back-pressure a Worker can read, so a Gate
292
+ * that writes every tick to a link that carries fewer leaves that player
293
+ * seconds behind for good: every frame arrives, each one later than the last.
294
+ * A client names the newest tick it holds (`g`, on its inputs and pings). While
295
+ * that trails the room by more than a second the Gate halves this player's
296
+ * rate, down to one a second; when it is back within half a second the rate
297
+ * doubles again. Each frame is a delta from the last one SENT, so a skipped
298
+ * tick costs nothing later, and the player's own body is in every frame.
299
+ * A client that names nothing is sent every tick, as before.
300
+ */
301
+ export function deliveryPace(hz = 20) {
302
+ let interval = 1, sent = -Infinity, got = null, changedAt = -Infinity, lag = null;
303
+ const most = Math.max(1, Math.round(hz));
304
+ return {
305
+ /** The client holds tick `tick`; the room is at `world`. `now` in ms. */
306
+ report(tick, world, now) {
307
+ if (!Number.isFinite(tick) || !Number.isFinite(world)) return;
308
+ // A new room epoch restarts its ticks: a tick from the old one says nothing.
309
+ got = tick > world ? null : tick;
310
+ lag = got === null ? null : world - got;
311
+ if (lag === null) return;
312
+ if (lag > hz && now - changedAt >= 500 && interval < most) { interval = Math.min(most, interval * 2); changedAt = now; }
313
+ else if (lag <= hz / 2 && now - changedAt >= 1000 && interval > 1) { interval = Math.max(1, interval / 2); changedAt = now; }
314
+ },
315
+ due(tick) { return tick < sent || tick - sent >= interval; },
316
+ sent(tick) { sent = tick; },
317
+ facts: () => ({ interval, lag }),
318
+ };
319
+ }
320
+ const REPORTED_TICK = /^\{"t":"(?:in|ping)".{0,400}?"g":(\d{1,12})[,}]/;
321
+ /** A client built with 0.46.2 moves a body it was not sent along its last velocity: it says so in its hello. */
322
+ const COASTS = /^\{"t":"hello".{0,2000}?"coast":1[,}]/;
323
+
324
+ const percentile = (sorted, p) => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : null;
325
+
94
326
  export class Gate {
95
- constructor(ctx, env) { this.ctx = ctx; this.env = env; this.clients = new Map(); this.sequence = 0; this.link = null; this.opening = null; }
327
+ 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; }
328
+ freshWindow() { return { worlds: 0, frames: 0, bytes: 0, skipped: 0, inputs: 0, worldAge: [], tickFrom: null, tickFromAt: 0, tickAt: 0, gapMax: 0 }; }
329
+ /** `[sent, received, answered]`: this Gate's clock when its frame left, the room's when it read it and when it answered. */
330
+ roomClock([sent, received, answered]) {
331
+ const now = Date.now(), offset = ((received - sent) + (answered - now)) / 2, delay = (now - sent) - (answered - received);
332
+ if (![offset, delay].every(Number.isFinite)) return;
333
+ // The exchange with the least delay is the truest reading of the offset; forget it slowly.
334
+ if (!this.clock || delay <= this.clock.delayMs || now - this.clock.at > 10_000) this.clock = { offsetMs: Math.round(offset), delayMs: Math.round(delay), heldMs: Math.round(answered - received), at: now };
335
+ }
96
336
  async upstream(url) {
97
337
  if (this.link) return this.link;
98
338
  if (this.opening) return this.opening;
99
339
  this.opening = (async () => {
100
340
  const name = `${url.searchParams.get('game')}/${url.searchParams.get('room')}`;
101
- const gates = Number(url.searchParams.get('gates'));
102
- const useConcentrator = !(this instanceof Concentrator) && gates > 8;
103
- const binding = useConcentrator ? this.env.CONCENTRATOR : this.env.TABLE;
104
- const key = useConcentrator ? `${name}/${Math.floor(Number(url.searchParams.get('gate')) / 8)}` : name;
341
+ // One ordered channel from each Gate to the room. A relay between them
342
+ // (0.45.0's Concentrator) parsed and re-sent every world for eight Gates
343
+ // and was the first object to fall behind; sixteen channels of one frame
344
+ // a tick each way are cheaper for the room than two of hundreds.
105
345
  const target = new URL(url); target.pathname = '/__multiplex';
106
- const response = await binding.get(binding.idFromName(key)).fetch(new Request(target, { headers: { Upgrade: 'websocket' } }));
346
+ const response = await this.env.TABLE.get(this.env.TABLE.idFromName(name)).fetch(new Request(target, { headers: { Upgrade: 'websocket' } }));
107
347
  if (!response.webSocket) throw new Error('room link refused');
108
348
  const socket = response.webSocket; socket.accept();
109
- const link = batchLink(socket);
110
- const lost = () => {
349
+ // What players send leaves this Gate as one frame at most every 50 ms: at once when the channel has been
350
+ // quiet, else with whatever arrived since the last. The room's one thread is spent by the message, so a
351
+ // Gate costs it one a tick each way. (Timing that frame to land just before the room's tick was tried twice
352
+ // and failed twice on Cloudflare: the round trip to the room varies by more than the window it aims for.)
353
+ const link = batchLink(socket, { receipts: true, delay: 50, maxFrames: 8, autoReceipt: false, onAck: (waited, clock) => { if (clock) this.roomClock(clock); } });
354
+ const sink = worldSink();
355
+ const lost = (event = {}) => {
111
356
  if (this.link !== link) return;
112
- this.link = null; link.close();
357
+ 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() }));
358
+ this.link = null; this.worlds = null; link.close();
359
+ for (const timer of this.admissions.values()) clearTimeout(timer); this.admissions.clear();
113
360
  for (const client of this.clients.values()) { try { client.close(1012, 'room link restarted'); } catch {} }
114
361
  this.clients.clear();
115
362
  };
363
+ const drop = (id, client) => { this.clients.delete(id); link.send(['close', id]); try { client.close(1011, 'client connection ended'); } catch {} };
116
364
  socket.addEventListener('message', event => {
117
- try { for (const [op, id, value, reason] of expandBatch(JSON.parse(event.data))) {
118
- const client = this.clients.get(id);
119
- if (op === 'data') {
120
- try { client?.send(value); } catch {
121
- this.clients.delete(id); link.send(['close', id]);
122
- try { client?.close(1011, 'client connection ended'); } catch {}
365
+ if (typeof event.data !== 'string') return;
366
+ try {
367
+ const rows = parseFrame(event); let hz = null;
368
+ for (const row of rows) {
369
+ 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; }
370
+ for (const [op, id, value, reason, settings, rate] of expandBatch([row])) {
371
+ const client = this.clients.get(id);
372
+ if (op === 'seat') { if (client) { client.seat = value; client.subscribed = true; } }
373
+ else if (op === 'view') { if (client) this.deliverOne(id, client, value, reason, settings, rate, drop); }
374
+ else if (op === 'data') { try { client?.send(value); } catch { if (client) drop(id, client); } }
375
+ else if (op === 'close') { this.clients.delete(id); try { client?.close(value, reason); } catch {} }
123
376
  }
124
- } else if (op === 'close') {
125
- this.clients.delete(id); try { client?.close(value, reason); } catch {}
126
377
  }
127
- } } catch { lost(); try { socket.close(1011, 'room link failed'); } catch {} }
378
+ link.consumed(rows);
379
+ if (hz && Date.now() - this.metricsAt >= 1000) this.publishMetrics(url, link);
380
+ } catch (error) { lost({ error, reason: 'invalid room frame' }); try { socket.close(1011, 'room link failed'); } catch {} }
128
381
  });
129
382
  socket.addEventListener('close', lost); socket.addEventListener('error', lost);
130
383
  this.link = link; return link;
131
384
  })();
132
385
  try { return await this.opening; } finally { this.opening = null; }
133
386
  }
387
+ /** One world from the room: every player this Gate holds gets their own view of it. */
388
+ deliver(snap, settings, hz, drop) {
389
+ const w = this.window, now = Date.now(); w.worlds++;
390
+ if (w.tickFrom === null) { w.tickFrom = this.worldTick ?? snap.k; w.tickFromAt = this.worldAt ?? now; }
391
+ if (this.worldAt) w.gapMax = Math.max(w.gapMax, now - this.worldAt);
392
+ this.worldTick = snap.k; this.worldAt = now; w.tickAt = now;
393
+ // By this Gate's clock, corrected for what the room's clock is measured to differ by.
394
+ if (Number.isFinite(snap.st)) w.worldAge.push(Math.max(0, now - snap.st + (this.clock?.offsetMs ?? 0)));
395
+ for (const [id, client] of this.clients) if (client.subscribed) this.deliverOne(id, client, snap, client.seat, settings, hz, drop);
396
+ }
397
+ deliverOne(id, client, snap, seat, settings, hz, drop) {
398
+ try {
399
+ if (client.snapshot) { client.snapshot(snap, seat, settings, hz); return; }
400
+ client.pace ??= deliveryPace(hz);
401
+ if (!client.pace.due(snap.k)) { this.window.skipped++; return; }
402
+ if (client.coasts) {
403
+ // A client that coasts (0.46.2) takes the crowd path: what does not depend on the player is done once a world.
404
+ const at = this.timing.now();
405
+ // A room's view settings are its game's, fixed for the life of the link.
406
+ this.worlds ??= crowdWorlds(settings ?? {});
407
+ client.crowd ??= crowdView(this.worlds, settings ?? {}, hz);
408
+ const text = client.crowd(snap, seat);
409
+ this.timing.record('encode', at);
410
+ client.send(text); client.pace.sent(snap.k);
411
+ this.window.frames++; this.window.bytes += text.length;
412
+ return;
413
+ }
414
+ const viewAt = this.timing.now();
415
+ client.viewSchedule ??= scheduledView(settings, hz);
416
+ // This encoder sits after coalescing on an ordered WebSocket. A
417
+ // fresh connection/epoch gets a keyframe; subsequent deltas cannot
418
+ // lose a predecessor, so do not repeat a full interest set each second.
419
+ client.snapEncoder ??= snapshotEncoder(Infinity, true);
420
+ const selected = client.viewSchedule(snap, seat);
421
+ this.timing.record('view', viewAt);
422
+ const encodeAt = this.timing.now();
423
+ const text = snapshotText({ t: 'snap', from: null, ...client.snapEncoder.encode(selected, seat) });
424
+ this.timing.record('encode', encodeAt);
425
+ const sendAt = this.timing.now();
426
+ client.send(text); client.pace.sent(snap.k);
427
+ this.window.frames++; this.window.bytes += text.length;
428
+ this.timing.record('send', sendAt);
429
+ } catch { drop(id, client); }
430
+ }
134
431
  async connect(request, server) {
135
432
  const link = await this.upstream(new URL(request.url));
136
433
  const id = String(++this.sequence);
137
434
  this.clients.set(id, server);
138
- link.send(['open', id, request.url, request.headers.get('cf-connecting-ip'), request.headers.get('user-agent')]);
139
- server.addEventListener('message', event => { if (typeof event.data === 'string') link.send(['data', id, event.data]); });
140
- const left = () => { if (this.clients.delete(id)) link.send(['close', id]); };
435
+ let opened = false;
436
+ const deadline = setTimeout(() => {
437
+ this.admissions.delete(id);
438
+ if (!opened && this.clients.delete(id)) { try { server.close(4000, 'no-hello'); } catch {} }
439
+ }, 5000);
440
+ deadline?.unref?.(); this.admissions.set(id, deadline);
441
+ server.addEventListener('message', event => {
442
+ if (!this.clients.has(id) || typeof event.data !== 'string') return;
443
+ // No legal public frame exceeds the largest checkpoint allowance
444
+ // (128 KiB). Reject it before it exhausts a shared relay's budget.
445
+ // The Table still enforces each message kind's smaller byte caps.
446
+ if (event.data.length > 128 * 1024) {
447
+ clearTimeout(deadline); this.admissions.delete(id); this.clients.delete(id);
448
+ if (opened) link.send(['close', id]);
449
+ try { server.close(1009, 'message too large'); } catch {}
450
+ return;
451
+ }
452
+ if (!opened) {
453
+ opened = true; clearTimeout(deadline); this.admissions.delete(id);
454
+ server.coasts = COASTS.test(event.data);
455
+ // Logical creation and the first frame share a batch: admission time at
456
+ // the Table excludes the public handshake and relay transit.
457
+ link.send(['open', id, request.url, request.headers.get('cf-connecting-ip'), request.headers.get('user-agent')]);
458
+ }
459
+ const now = Date.now();
460
+ const said = REPORTED_TICK.exec(event.data);
461
+ if (said && server.pace && this.worldTick !== null) server.pace.report(Number(said[1]), this.worldTick, now);
462
+ this.window.inputs++;
463
+ // Only the public Gate stamps ingress; a player cannot supply it inside their JSON.
464
+ link.send(['data', id, event.data, Number.isFinite(event.receivedAt) ? event.receivedAt : now]);
465
+ });
466
+ const left = () => { clearTimeout(deadline); this.admissions.delete(id); if (this.clients.delete(id) && opened) link.send(['close', id]); };
141
467
  server.addEventListener('close', left); server.addEventListener('error', left);
142
468
  }
469
+ /** What this Gate did in the last second, for whoever is measuring the room. */
470
+ gateFacts(link, now = Date.now()) {
471
+ const w = this.window, seconds = Math.max(0.001, (now - (this.metricsAt || now - 1000)) / 1000);
472
+ const subscribed = [...this.clients.values()].filter(c => c.subscribed);
473
+ const paces = subscribed.map(c => c.pace?.facts()).filter(Boolean);
474
+ const lags = paces.map(p => p.lag).filter(Number.isFinite).sort((a, b) => a - b), ages = w.worldAge.sort((a, b) => a - b);
475
+ return {
476
+ ...link.facts(), view: this.timing.facts(), clients: this.clients.size, subscribed: subscribed.length, tick: this.worldTick,
477
+ // Per second: worlds received from the room, and frames written to each player on average.
478
+ worldHz: Math.round(w.worlds / seconds * 100) / 100,
479
+ deliveredHz: subscribed.length ? Math.round(w.frames / seconds / subscribed.length * 100) / 100 : null,
480
+ bytesPerSecond: Math.round(w.bytes / seconds), inputsPerSecond: Math.round(w.inputs / seconds), skipped: w.skipped,
481
+ // Room stamp to this Gate writing it to players, ms (both are Cloudflare's clock at the start of an event).
482
+ worldAgeMs: { p50: percentile(ages, 0.5), p95: percentile(ages, 0.95), max: ages.at(-1) ?? null },
483
+ // How far behind the room players say they are, in ticks, and how many are being sent fewer frames for it.
484
+ clientLagTicks: { reporting: lags.length, p50: percentile(lags, 0.5), p95: percentile(lags, 0.95), max: lags.at(-1) ?? null },
485
+ paced: paces.filter(p => p.interval > 1).length,
486
+ coasting: subscribed.filter(c => c.coasts).length,
487
+ // Ticks the room's worlds advanced a second, by THIS Gate's clock: the room's own clock cannot see itself run late.
488
+ roomTickHz: w.tickFrom !== null && w.tickAt > w.tickFromAt ? Math.round((this.worldTick - w.tickFrom) / (w.tickAt - w.tickFromAt) * 100000) / 100 : null,
489
+ worldGapMs: w.gapMax,
490
+ // The room's clock less this Gate's, and the channel's delay, from stamps exchanged on the channel (as NTP does).
491
+ roomClock: this.clock ?? null,
492
+ };
493
+ }
494
+ publishMetrics(url, link) {
495
+ const now = Date.now(), facts = this.gateFacts(link, now);
496
+ this.metricsAt = now; this.window = this.freshWindow();
497
+ if (!this.env.GATE || this.metricsPending) return;
498
+ this.metricsPending = true;
499
+ const work = diagnosticsOf(this.env, url.searchParams.get('game'), url.searchParams.get('room')).fetch('https://diagnostics/__diagnostics', {
500
+ method: 'POST', body: JSON.stringify({ kind: 'gate', id: `gate/${url.searchParams.get('gate')}`, at: now, ...facts }),
501
+ }).then(r => r.body?.cancel?.()).catch(() => {}).finally(() => { this.metricsPending = false; });
502
+ this.ctx.waitUntil?.(work);
503
+ }
504
+ /** The room's diagnostics, kept by an object that is neither the room nor a
505
+ * Gate with players: the room and its Gates each post a second's facts here,
506
+ * and a reader is answered from the copy. Measuring a room costs it nothing,
507
+ * and a room too busy to answer still has its last word (and its age) read.
508
+ */
509
+ async diagnosticsFetch(request, url) {
510
+ if (request.method === 'POST') {
511
+ const value = await request.json();
512
+ if (value.kind === 'gate') { this.gateDiagnostics ??= new Map(); this.gateDiagnostics.set(value.id, value); }
513
+ else this.diagnostics = value;
514
+ return new Response(null, { status: 204 });
515
+ }
516
+ const at = Date.now();
517
+ for (const [id, value] of this.gateDiagnostics ?? []) if (at - value.at > 15_000) this.gateDiagnostics.delete(id);
518
+ const gates = [...(this.gateDiagnostics?.values() ?? [])].sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }));
519
+ 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 } });
520
+ }
143
521
  async fetch(request) {
144
- if (new URL(request.url).pathname === '/__multiplex') return acceptMultiplex(request, (req, socket) => this.connect(req, socket));
522
+ const url = new URL(request.url);
523
+ if (url.pathname === '/__diagnostics') return this.diagnosticsFetch(request, url);
524
+ if (url.pathname === '/__multiplex') return acceptMultiplex(request, (req, socket) => this.connect(req, socket));
145
525
  const [client, server] = Object.values(new WebSocketPair());
146
526
  server.accept();
147
527
  await this.connect(request, server);
148
528
  return new Response(null, { status: 101, webSocket: client });
149
529
  }
150
530
  }
531
+ /** The object that keeps one room's diagnostics (a Gate by class, never one with players). */
532
+ export function diagnosticsOf(env, game, room) { return env.GATE.get(env.GATE.idFromName(`${game}/${room}/diagnostics`)); }
533
+ /** Kept so a studio deployed with 0.45.0's Concentrator binding still loads; nothing opens one. */
151
534
  export class Concentrator extends Gate {}