@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.
- package/CHANGELOG.md +66 -0
- package/README.md +2 -0
- package/bin/homie-studio.mjs +1 -1
- package/docs/custom-routes.md +72 -0
- package/guides/app/SKILL.md +7 -0
- package/guides/playtest/scripts/playtest.mjs +7 -5
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +4 -1
- package/lib/check.mjs +8 -1
- package/lib/cloudflare.mjs +65 -23
- package/lib/dev.mjs +6 -8
- package/lib/local-database-start.mjs +6 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/port-check.mjs +3 -2
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +30 -10
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +87 -0
- package/lib/worker-build.mjs +16 -4
- package/lib/worker-config.mjs +10 -1
- package/netplay/NETPLAY.md +5 -1
- package/netplay/netplay.ts +24 -9
- package/package.json +1 -1
- package/rules/core.ts +9 -0
- package/rules/host.ts +40 -11
- package/rules/view.ts +1 -0
- package/tools/TOOLS.md +6 -1
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-streams.mjs +3 -3
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +31 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/custom-routes.mjs +16 -0
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +23 -11
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +106 -91
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/idle-chat.mjs +56 -0
- package/worker/index.mjs +56 -2785
- package/worker/lazy-objects.mjs +43 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby-peak.mjs +44 -0
- package/worker/lobby-shards.mjs +43 -0
- package/worker/lobby-store.mjs +35 -0
- package/worker/lobby-summary.mjs +28 -0
- package/worker/lobby.mjs +319 -0
- package/worker/lounge-store.mjs +2 -2
- package/worker/lounge.mjs +3 -3
- package/worker/match-router.mjs +74 -0
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-room.mjs +2 -1
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp-tools.mjs +4 -2
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +18 -21
- package/worker/operational-schema.mjs +16 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +9 -29
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/relay-channel.mjs +119 -0
- package/worker/request-db.mjs +36 -10
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +217 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room-sessions.mjs +65 -0
- package/worker/room.mjs +54 -740
- package/worker/rooms-cache.mjs +19 -0
- package/worker/route-auth.mjs +22 -0
- package/worker/routes.mjs +670 -0
- package/worker/runtime-support.mjs +447 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/service-run.mjs +1 -1
- package/worker/session-codec.mjs +24 -0
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop.mjs +27 -20
- package/worker/stats.mjs +9 -7
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1448 -0
- package/worker/tool-registry.mjs +10 -0
- package/worker/version.mjs +1 -1
- package/worker/watch-sockets.mjs +75 -0
package/worker/gate.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {WatchSockets} from './watch-sockets.mjs';
|
|
2
|
+
import {isQa} from './stats.mjs';
|
|
3
|
+
import {relayChannel,readRelay,retryRelay} from './relay-channel.mjs';
|
|
1
4
|
import { STUDIO_BUILD } from './build-id.mjs';
|
|
2
5
|
import { stageTimings } from '../rules/timing.mjs';
|
|
3
6
|
import { crowdView, crowdWorlds, scheduledView, snapshotEncoder, snapshotText } from '../rules/interest.mjs';
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
if (
|
|
376
|
-
|
|
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:
|
|
382
|
-
|
|
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
|
|
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
|
-
|
|
470
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
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
|
+
}
|