@irtio/cli 0.1.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/LICENSE +21 -0
- package/dist/bundle.d.ts +36 -0
- package/dist/bundle.js +10 -0
- package/dist/chunk-J3G6AUJY.js +85 -0
- package/dist/chunk-UYN5PWLT.js +59 -0
- package/dist/chunk-ZWCLCYCS.js +146 -0
- package/dist/deploy-4W6AYYEW.js +308 -0
- package/dist/dev-BFWFWJ4J.js +2783 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +90 -0
- package/dist/init.d.ts +64 -0
- package/dist/init.js +307 -0
- package/dist/login-US2YT5ZB.js +136 -0
- package/dist/logs-6GLBO3TP.js +122 -0
- package/dist/migrate-FQIDF747.js +206 -0
- package/dist/rooms-JXT3PRHN.js +102 -0
- package/dist/simulate.d.ts +62 -0
- package/dist/simulate.js +221 -0
- package/dist/whoami-5Q32SXFX.js +63 -0
- package/package.json +46 -0
|
@@ -0,0 +1,2783 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BundleError,
|
|
3
|
+
bundleRoom
|
|
4
|
+
} from "./chunk-ZWCLCYCS.js";
|
|
5
|
+
|
|
6
|
+
// src/dev.ts
|
|
7
|
+
import { existsSync } from "fs";
|
|
8
|
+
import { watch } from "fs";
|
|
9
|
+
import { mkdir as mkdir2, readFile as readFile2 } from "fs/promises";
|
|
10
|
+
import * as path2 from "path";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
|
|
13
|
+
// ../supervisor/src/contract.ts
|
|
14
|
+
var DEFAULT_LIMITS = {
|
|
15
|
+
framesPerSec: 240,
|
|
16
|
+
hellosPerMin: 600,
|
|
17
|
+
connectionsPerIpPerMin: 120,
|
|
18
|
+
maxBufferedBytes: 4 * 1024 * 1024,
|
|
19
|
+
workerMaxOldGenMb: 256,
|
|
20
|
+
workerMaxYoungGenMb: 32,
|
|
21
|
+
snapshotEveryMs: 3e4,
|
|
22
|
+
maxRestartsPerMin: 3,
|
|
23
|
+
helloTimeoutMs: 5e3,
|
|
24
|
+
workerReadyTimeoutMs: 1e4,
|
|
25
|
+
relayIdleMs: 3e4,
|
|
26
|
+
relayReconnectGraceMs: 3e4,
|
|
27
|
+
relayMaxClients: 64
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ../supervisor/src/resilience.ts
|
|
31
|
+
var SNAPSHOT_FLUSH_MAX_RETRIES = 3;
|
|
32
|
+
var SNAPSHOT_FLUSH_RETRY_DELAYS_MS = [100, 200, 400];
|
|
33
|
+
var JITTER_RATIO = 0.2;
|
|
34
|
+
function delayWithJitter(ms) {
|
|
35
|
+
const jitter = ms * JITTER_RATIO * (Math.random() * 2 - 1);
|
|
36
|
+
return Math.max(0, ms + jitter);
|
|
37
|
+
}
|
|
38
|
+
function sleep(ms) {
|
|
39
|
+
return new Promise((resolve2) => {
|
|
40
|
+
const t = setTimeout(resolve2, ms);
|
|
41
|
+
t.unref?.();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async function putSnapshotWithRetry(store, key, bytes, metrics, log) {
|
|
45
|
+
let lastErr;
|
|
46
|
+
for (let attempt = 0; attempt <= SNAPSHOT_FLUSH_MAX_RETRIES; attempt++) {
|
|
47
|
+
if (attempt > 0) {
|
|
48
|
+
const delay = SNAPSHOT_FLUSH_RETRY_DELAYS_MS[attempt - 1] ?? 0;
|
|
49
|
+
await sleep(delayWithJitter(delay));
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
await store.put(key, bytes);
|
|
53
|
+
return true;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
lastErr = err;
|
|
56
|
+
log("warn", `snapshot flush attempt ${attempt + 1} failed for ${key}`, err);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
metrics.snapshotFlushFailures++;
|
|
60
|
+
log(
|
|
61
|
+
"error",
|
|
62
|
+
`snapshot flush abandoned for ${key} after ${SNAPSHOT_FLUSH_MAX_RETRIES + 1} attempts`,
|
|
63
|
+
lastErr
|
|
64
|
+
);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ../store/dist/index.js
|
|
69
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from "fs/promises";
|
|
70
|
+
import * as path from "path";
|
|
71
|
+
var DiskStore = class {
|
|
72
|
+
constructor(dir) {
|
|
73
|
+
this.dir = dir;
|
|
74
|
+
}
|
|
75
|
+
dir;
|
|
76
|
+
fileFor(key) {
|
|
77
|
+
if (!/^[A-Za-z0-9_.@/-]+$/.test(key) || key.includes("..")) {
|
|
78
|
+
throw new Error(`invalid object store key ${JSON.stringify(key)}`);
|
|
79
|
+
}
|
|
80
|
+
return path.join(this.dir, `${key}.snap`);
|
|
81
|
+
}
|
|
82
|
+
async put(key, bytes) {
|
|
83
|
+
const file = this.fileFor(key);
|
|
84
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
85
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
86
|
+
await writeFile(tmp, bytes);
|
|
87
|
+
for (let attempt = 0; ; attempt++) {
|
|
88
|
+
try {
|
|
89
|
+
await rename(tmp, file);
|
|
90
|
+
return;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
const code = err.code;
|
|
93
|
+
if ((code === "EPERM" || code === "EBUSY") && attempt < 10) {
|
|
94
|
+
await new Promise((resolve2) => setTimeout(resolve2, 5 * (attempt + 1)));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async get(key) {
|
|
102
|
+
try {
|
|
103
|
+
return new Uint8Array(await readFile(this.fileFor(key)));
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (err.code === "ENOENT") return void 0;
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async delete(key) {
|
|
110
|
+
await rm(this.fileFor(key), { force: true });
|
|
111
|
+
}
|
|
112
|
+
async list(prefix) {
|
|
113
|
+
const out = [];
|
|
114
|
+
const walk = async (dir, rel) => {
|
|
115
|
+
let entries;
|
|
116
|
+
try {
|
|
117
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err.code === "ENOENT") return;
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
for (const e of entries) {
|
|
123
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
124
|
+
if (e.isDirectory()) await walk(path.join(dir, e.name), r);
|
|
125
|
+
else if (e.name.endsWith(".snap")) out.push(r.slice(0, -".snap".length));
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
await walk(this.dir, "");
|
|
129
|
+
return out.filter((k) => k.startsWith(prefix)).sort();
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// ../supervisor/src/server.ts
|
|
134
|
+
import { execFile } from "child_process";
|
|
135
|
+
import { randomBytes, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
136
|
+
import { createServer } from "http";
|
|
137
|
+
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
138
|
+
import {
|
|
139
|
+
ErrorCode as ErrorCode2,
|
|
140
|
+
FrameType as FrameType2,
|
|
141
|
+
PROTOCOL_VERSION,
|
|
142
|
+
decodeFrame,
|
|
143
|
+
decodeHello,
|
|
144
|
+
decodeMsg,
|
|
145
|
+
decodePing,
|
|
146
|
+
encodeErrorPayload as encodeErrorPayload2,
|
|
147
|
+
encodeFrame as encodeFrame2,
|
|
148
|
+
encodeMsg,
|
|
149
|
+
encodePong,
|
|
150
|
+
encodeWelcome,
|
|
151
|
+
formatError,
|
|
152
|
+
isRelayHash8,
|
|
153
|
+
relaySchema as relaySchema2,
|
|
154
|
+
withBuiltins
|
|
155
|
+
} from "@irtio/protocol";
|
|
156
|
+
import { inspectState } from "@irtio/runtime";
|
|
157
|
+
import { bytesEqual } from "@irtio/schema";
|
|
158
|
+
import { isRoomDefinition } from "@irtio/server";
|
|
159
|
+
import { WebSocketServer } from "ws";
|
|
160
|
+
|
|
161
|
+
// ../supervisor/src/auth.ts
|
|
162
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
163
|
+
var TokenBucket = class {
|
|
164
|
+
constructor(capacity, refillPerSec) {
|
|
165
|
+
this.capacity = capacity;
|
|
166
|
+
this.refillPerSec = refillPerSec;
|
|
167
|
+
this.tokens = capacity;
|
|
168
|
+
}
|
|
169
|
+
capacity;
|
|
170
|
+
refillPerSec;
|
|
171
|
+
tokens;
|
|
172
|
+
last = Date.now();
|
|
173
|
+
/** Takes `n` tokens; `false` when the bucket is dry (caller decides the penalty). */
|
|
174
|
+
take(n = 1) {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
const elapsed = Math.max(0, now - this.last) / 1e3;
|
|
177
|
+
this.last = now;
|
|
178
|
+
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSec);
|
|
179
|
+
if (this.tokens < n) return false;
|
|
180
|
+
this.tokens -= n;
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
var BucketMap = class {
|
|
185
|
+
constructor(make) {
|
|
186
|
+
this.make = make;
|
|
187
|
+
}
|
|
188
|
+
make;
|
|
189
|
+
map = /* @__PURE__ */ new Map();
|
|
190
|
+
take(key, n = 1) {
|
|
191
|
+
let entry = this.map.get(key);
|
|
192
|
+
if (!entry) {
|
|
193
|
+
entry = { bucket: this.make(), at: 0 };
|
|
194
|
+
this.map.set(key, entry);
|
|
195
|
+
}
|
|
196
|
+
entry.at = Date.now();
|
|
197
|
+
return entry.bucket.take(n);
|
|
198
|
+
}
|
|
199
|
+
sweep(maxIdleMs = 10 * 6e4) {
|
|
200
|
+
const cutoff = Date.now() - maxIdleMs;
|
|
201
|
+
for (const [key, entry] of this.map) {
|
|
202
|
+
if (entry.at < cutoff) this.map.delete(key);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
function originAllowed(origins, allowNoOrigin, origin) {
|
|
207
|
+
if (origin === void 0 || origin === "") return allowNoOrigin;
|
|
208
|
+
if (origins.includes("*")) return true;
|
|
209
|
+
return origins.includes(origin);
|
|
210
|
+
}
|
|
211
|
+
function mac(secret, payload) {
|
|
212
|
+
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
213
|
+
}
|
|
214
|
+
function signResume(secret, p) {
|
|
215
|
+
const payload = `${p.clientId}|${p.roomId}|${encodeURIComponent(p.role)}|${p.expMs}`;
|
|
216
|
+
return Buffer.from(`${payload}.${mac(secret, payload)}`, "utf8").toString("base64url");
|
|
217
|
+
}
|
|
218
|
+
function verifyResume(secret, token, now = Date.now()) {
|
|
219
|
+
let decoded;
|
|
220
|
+
try {
|
|
221
|
+
decoded = Buffer.from(token, "base64url").toString("utf8");
|
|
222
|
+
} catch {
|
|
223
|
+
return void 0;
|
|
224
|
+
}
|
|
225
|
+
const dot = decoded.lastIndexOf(".");
|
|
226
|
+
if (dot <= 0) return void 0;
|
|
227
|
+
const payload = decoded.slice(0, dot);
|
|
228
|
+
const provided = decoded.slice(dot + 1);
|
|
229
|
+
const expected = mac(secret, payload);
|
|
230
|
+
const a = Buffer.from(provided, "utf8");
|
|
231
|
+
const b = Buffer.from(expected, "utf8");
|
|
232
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) return void 0;
|
|
233
|
+
const parts = payload.split("|");
|
|
234
|
+
if (parts.length !== 4) return void 0;
|
|
235
|
+
const [clientId, roomId, encodedRole, expText] = parts;
|
|
236
|
+
const expMs = Number(expText);
|
|
237
|
+
if (!Number.isFinite(expMs) || expMs <= now) return void 0;
|
|
238
|
+
let role;
|
|
239
|
+
try {
|
|
240
|
+
role = decodeURIComponent(encodedRole);
|
|
241
|
+
} catch {
|
|
242
|
+
return void 0;
|
|
243
|
+
}
|
|
244
|
+
return { clientId, roomId, role, expMs };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ../supervisor/src/codes.ts
|
|
248
|
+
import { randomInt } from "crypto";
|
|
249
|
+
var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
250
|
+
var ROOM_ID_RE = /^[A-Za-z0-9_-]{1,32}$/;
|
|
251
|
+
var CODE_SHAPE_RE = /^[A-Za-z0-9]{4,5}$/;
|
|
252
|
+
function pick(n) {
|
|
253
|
+
let out = "";
|
|
254
|
+
for (let i = 0; i < n; i++) out += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
function newRoomCode(taken) {
|
|
258
|
+
for (let len = 4; len <= 8; len++) {
|
|
259
|
+
for (let attempt = 0; attempt < 64; attempt++) {
|
|
260
|
+
const code = pick(len);
|
|
261
|
+
if (!taken(code)) return code;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
throw new Error("newRoomCode: exhausted the room code space");
|
|
265
|
+
}
|
|
266
|
+
function newClientId(taken) {
|
|
267
|
+
for (let attempt = 0; attempt < 128; attempt++) {
|
|
268
|
+
const id = `c${pick(10)}`;
|
|
269
|
+
if (!taken(id)) return id;
|
|
270
|
+
}
|
|
271
|
+
throw new Error("newClientId: could not allocate a unique client id");
|
|
272
|
+
}
|
|
273
|
+
function looksLikeRoomCode(id) {
|
|
274
|
+
if (!CODE_SHAPE_RE.test(id)) return false;
|
|
275
|
+
for (const ch of id.toUpperCase()) {
|
|
276
|
+
if (!CODE_ALPHABET.includes(ch)) return false;
|
|
277
|
+
}
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
function normalizeRoomId(raw) {
|
|
281
|
+
if (!ROOM_ID_RE.test(raw)) return void 0;
|
|
282
|
+
return looksLikeRoomCode(raw) ? raw.toUpperCase() : raw;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ../supervisor/src/relay.ts
|
|
286
|
+
import { PRESENCE_COLLECTION, relaySchema } from "@irtio/protocol";
|
|
287
|
+
import {
|
|
288
|
+
EntityCollection,
|
|
289
|
+
createDirtySet,
|
|
290
|
+
createState,
|
|
291
|
+
decodeSnapshot,
|
|
292
|
+
encodeDelta,
|
|
293
|
+
encodeSnapshot,
|
|
294
|
+
markAdd,
|
|
295
|
+
markField,
|
|
296
|
+
markRemove
|
|
297
|
+
} from "@irtio/schema";
|
|
298
|
+
var CLIENTS = relaySchema.collection(PRESENCE_COLLECTION);
|
|
299
|
+
function fieldIndex(name) {
|
|
300
|
+
const idx = CLIENTS.fieldIndex.get(name);
|
|
301
|
+
if (idx === void 0) throw new Error(`relaySchema presence has no ${name} field`);
|
|
302
|
+
return idx;
|
|
303
|
+
}
|
|
304
|
+
var ROLE_IDX = fieldIndex("role");
|
|
305
|
+
var NAME_IDX = fieldIndex("name");
|
|
306
|
+
var CONNECTED_IDX = fieldIndex("connected");
|
|
307
|
+
var RelayRoom = class _RelayRoom {
|
|
308
|
+
constructor(state, tick) {
|
|
309
|
+
this.state = state;
|
|
310
|
+
this.tick = tick;
|
|
311
|
+
}
|
|
312
|
+
state;
|
|
313
|
+
tick;
|
|
314
|
+
static create() {
|
|
315
|
+
return new _RelayRoom(createState(relaySchema), 0);
|
|
316
|
+
}
|
|
317
|
+
/** Restores presence from a hibernation snapshot (same bytes `serialize()` produced). */
|
|
318
|
+
static restore(bytes) {
|
|
319
|
+
const decoded = decodeSnapshot(relaySchema, bytes);
|
|
320
|
+
return new _RelayRoom(decoded.state, decoded.tick);
|
|
321
|
+
}
|
|
322
|
+
get clients() {
|
|
323
|
+
const coll = this.state[PRESENCE_COLLECTION];
|
|
324
|
+
if (!(coll instanceof EntityCollection)) throw new Error("relay presence collection missing");
|
|
325
|
+
return coll;
|
|
326
|
+
}
|
|
327
|
+
has(clientId) {
|
|
328
|
+
return this.clients.has(clientId);
|
|
329
|
+
}
|
|
330
|
+
get(clientId) {
|
|
331
|
+
return this.clients.get(clientId);
|
|
332
|
+
}
|
|
333
|
+
/** Client ids in join order. */
|
|
334
|
+
ids() {
|
|
335
|
+
return [...this.clients.ids()];
|
|
336
|
+
}
|
|
337
|
+
get size() {
|
|
338
|
+
return this.clients.size;
|
|
339
|
+
}
|
|
340
|
+
/** Adds (or refreshes, on resume) one client. Returns the presence DELTA for everyone else. */
|
|
341
|
+
add(clientId, role, name) {
|
|
342
|
+
const dirty = createDirtySet();
|
|
343
|
+
const existing = this.clients.get(clientId);
|
|
344
|
+
if (existing) {
|
|
345
|
+
existing.role = role;
|
|
346
|
+
existing.name = name;
|
|
347
|
+
existing.connected = true;
|
|
348
|
+
markField(dirty, PRESENCE_COLLECTION, clientId, [ROLE_IDX]);
|
|
349
|
+
markField(dirty, PRESENCE_COLLECTION, clientId, [NAME_IDX]);
|
|
350
|
+
markField(dirty, PRESENCE_COLLECTION, clientId, [CONNECTED_IDX]);
|
|
351
|
+
} else {
|
|
352
|
+
this.clients.add(clientId, { clientId, role, name, connected: true }, { owner: "" });
|
|
353
|
+
markAdd(dirty, PRESENCE_COLLECTION, clientId);
|
|
354
|
+
}
|
|
355
|
+
this.tick++;
|
|
356
|
+
return encodeDelta(relaySchema, this.state, dirty, { tick: this.tick });
|
|
357
|
+
}
|
|
358
|
+
/** Flips the `connected` flag (grace window opens/closes). `undefined` when nothing changed. */
|
|
359
|
+
setConnected(clientId, connected) {
|
|
360
|
+
const rec = this.clients.get(clientId);
|
|
361
|
+
if (!rec || rec.connected === connected) return void 0;
|
|
362
|
+
rec.connected = connected;
|
|
363
|
+
const dirty = createDirtySet();
|
|
364
|
+
markField(dirty, PRESENCE_COLLECTION, clientId, [CONNECTED_IDX]);
|
|
365
|
+
this.tick++;
|
|
366
|
+
return encodeDelta(relaySchema, this.state, dirty, { tick: this.tick });
|
|
367
|
+
}
|
|
368
|
+
/** Removes a client (grace expired). Remaining clients keep their join order. */
|
|
369
|
+
remove(clientId) {
|
|
370
|
+
if (!this.clients.remove(clientId)) return void 0;
|
|
371
|
+
const dirty = createDirtySet();
|
|
372
|
+
markRemove(dirty, PRESENCE_COLLECTION, clientId);
|
|
373
|
+
this.tick++;
|
|
374
|
+
return encodeDelta(relaySchema, this.state, dirty, { tick: this.tick });
|
|
375
|
+
}
|
|
376
|
+
/** Bumps the tick for a forwarded MSG batch (relay rooms have no other clock). */
|
|
377
|
+
advance() {
|
|
378
|
+
return ++this.tick;
|
|
379
|
+
}
|
|
380
|
+
/** Full-state snapshot for `WELCOME` (and for the hibernation store — same bytes). */
|
|
381
|
+
snapshot() {
|
|
382
|
+
return encodeSnapshot(relaySchema, this.state, { tick: this.tick });
|
|
383
|
+
}
|
|
384
|
+
serialize() {
|
|
385
|
+
return this.snapshot();
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// ../supervisor/src/hibernate.ts
|
|
390
|
+
function hibernateRoom(sup, room) {
|
|
391
|
+
if (room.state !== "running") return room.transition;
|
|
392
|
+
room.state = "hibernating";
|
|
393
|
+
const work = (async () => {
|
|
394
|
+
const worker = room.worker;
|
|
395
|
+
if (worker) {
|
|
396
|
+
const bytes = await worker.serialize();
|
|
397
|
+
if (bytes) {
|
|
398
|
+
const ok = await putSnapshotWithRetry(
|
|
399
|
+
sup.store,
|
|
400
|
+
sup.storeKey(room),
|
|
401
|
+
bytes,
|
|
402
|
+
sup.processMetrics,
|
|
403
|
+
(level, ...args) => sup.roomLog(room, level, ...args)
|
|
404
|
+
);
|
|
405
|
+
if (!ok) {
|
|
406
|
+
room.state = "running";
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
} else sup.roomLog(room, "warn", "hibernate: the worker produced no snapshot");
|
|
410
|
+
worker.post({ t: "stop" });
|
|
411
|
+
await worker.terminate();
|
|
412
|
+
}
|
|
413
|
+
room.worker = void 0;
|
|
414
|
+
sup.clearRoomTimer(room);
|
|
415
|
+
room.state = "hibernated";
|
|
416
|
+
room.metrics.hibernations++;
|
|
417
|
+
sup.roomLog(room, "info", "hibernated");
|
|
418
|
+
})();
|
|
419
|
+
room.transition = work.catch((err) => {
|
|
420
|
+
sup.roomLog(room, "error", "hibernate failed", err);
|
|
421
|
+
room.state = "running";
|
|
422
|
+
});
|
|
423
|
+
return room.transition.then(() => afterSleep(sup, room));
|
|
424
|
+
}
|
|
425
|
+
function hibernateRelay(sup, room) {
|
|
426
|
+
if (room.state !== "running" || !room.relay) return room.transition;
|
|
427
|
+
room.state = "hibernating";
|
|
428
|
+
const work = (async () => {
|
|
429
|
+
const relay = room.relayRoom;
|
|
430
|
+
if (relay) {
|
|
431
|
+
const ok = await putSnapshotWithRetry(
|
|
432
|
+
sup.store,
|
|
433
|
+
sup.storeKey(room),
|
|
434
|
+
relay.serialize(),
|
|
435
|
+
sup.processMetrics,
|
|
436
|
+
(level, ...args) => sup.roomLog(room, level, ...args)
|
|
437
|
+
);
|
|
438
|
+
if (!ok) {
|
|
439
|
+
room.state = "running";
|
|
440
|
+
sup.armRelayIdle(room);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
room.relayRoom = void 0;
|
|
445
|
+
sup.clearRoomTimer(room);
|
|
446
|
+
room.state = "hibernated";
|
|
447
|
+
room.metrics.hibernations++;
|
|
448
|
+
sup.roomLog(room, "info", "relay room hibernated");
|
|
449
|
+
})();
|
|
450
|
+
room.transition = work.catch((err) => {
|
|
451
|
+
sup.roomLog(room, "error", "relay hibernate failed", err);
|
|
452
|
+
room.state = "running";
|
|
453
|
+
});
|
|
454
|
+
return room.transition.then(() => afterSleep(sup, room));
|
|
455
|
+
}
|
|
456
|
+
async function afterSleep(sup, room) {
|
|
457
|
+
if (room.state !== "hibernated") return;
|
|
458
|
+
if (room.wakeQueue.length > 0) {
|
|
459
|
+
await wakeRoom(sup, room);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
sup.dropRoomIfIdle(room);
|
|
463
|
+
}
|
|
464
|
+
async function wakeRoom(sup, room) {
|
|
465
|
+
if (room.state === "waking" || room.state === "starting" || room.state === "hibernating") {
|
|
466
|
+
await room.transition;
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (room.state !== "hibernated") return;
|
|
470
|
+
room.state = "waking";
|
|
471
|
+
const startedAt = performance.now();
|
|
472
|
+
const work = (async () => {
|
|
473
|
+
const snapshot = await sup.store.get(sup.storeKey(room));
|
|
474
|
+
if (room.relay) {
|
|
475
|
+
room.relayRoom = snapshot ? RelayRoom.restore(snapshot) : RelayRoom.create();
|
|
476
|
+
sup.reconcileRelayPresence(room);
|
|
477
|
+
room.lastTick = room.relayRoom.tick;
|
|
478
|
+
for (const session of room.connected()) {
|
|
479
|
+
sup.sendWelcome(session, room.relayRoom.tick, room.relayRoom.snapshot());
|
|
480
|
+
}
|
|
481
|
+
} else {
|
|
482
|
+
const started = await sup.startWorker(room, snapshot);
|
|
483
|
+
if (!started) {
|
|
484
|
+
sup.closeRoom(room, "E_INTERNAL", "room failed to wake");
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
await sup.rejoinAll(room);
|
|
488
|
+
for (const clientId of room.pendingLeaves) {
|
|
489
|
+
room.worker?.post({ t: "leave", clientId, reason: "timeout" });
|
|
490
|
+
}
|
|
491
|
+
room.pendingLeaves.clear();
|
|
492
|
+
}
|
|
493
|
+
const queued = room.wakeQueue.splice(0);
|
|
494
|
+
for (const frame of queued) sup.deliverQueued(room, frame);
|
|
495
|
+
if (room.state === "waking") room.state = "running";
|
|
496
|
+
room.metrics.wakes++;
|
|
497
|
+
const ms = performance.now() - startedAt;
|
|
498
|
+
room.metrics.lastWakeMs = ms;
|
|
499
|
+
if (ms > room.metrics.maxWakeMs) room.metrics.maxWakeMs = ms;
|
|
500
|
+
sup.roomLog(room, "info", `woke in ${ms.toFixed(1)} ms`);
|
|
501
|
+
})();
|
|
502
|
+
room.transition = work.catch((err) => {
|
|
503
|
+
sup.roomLog(room, "error", "wake failed", err);
|
|
504
|
+
sup.closeRoom(room, "E_INTERNAL", "room failed to wake");
|
|
505
|
+
});
|
|
506
|
+
await room.transition;
|
|
507
|
+
}
|
|
508
|
+
async function flushAll(sup) {
|
|
509
|
+
for (const room of sup.registry.values()) {
|
|
510
|
+
await room.transition.catch(() => {
|
|
511
|
+
});
|
|
512
|
+
if (room.state === "closed") continue;
|
|
513
|
+
if (room.relay) {
|
|
514
|
+
sup.clearRoomTimer(room);
|
|
515
|
+
room.relayRoom = void 0;
|
|
516
|
+
room.state = "hibernated";
|
|
517
|
+
sup.registry.delete(room.id);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const worker = room.worker;
|
|
521
|
+
if (worker) {
|
|
522
|
+
try {
|
|
523
|
+
const bytes = await worker.serialize();
|
|
524
|
+
if (bytes) {
|
|
525
|
+
await putSnapshotWithRetry(
|
|
526
|
+
sup.store,
|
|
527
|
+
sup.storeKey(room),
|
|
528
|
+
bytes,
|
|
529
|
+
sup.processMetrics,
|
|
530
|
+
(level, ...args) => sup.roomLog(room, level, ...args)
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
} catch (err) {
|
|
534
|
+
sup.roomLog(room, "error", "flush: snapshot failed", err);
|
|
535
|
+
}
|
|
536
|
+
worker.post({ t: "stop" });
|
|
537
|
+
await worker.terminate();
|
|
538
|
+
room.worker = void 0;
|
|
539
|
+
room.metrics.hibernations++;
|
|
540
|
+
}
|
|
541
|
+
sup.clearRoomTimer(room);
|
|
542
|
+
room.state = "hibernated";
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ../supervisor/src/metrics.ts
|
|
547
|
+
var LOG_RING_SIZE = 500;
|
|
548
|
+
function newProcessMetrics() {
|
|
549
|
+
return {
|
|
550
|
+
unhandledRejections: 0,
|
|
551
|
+
snapshotFlushFailures: 0
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
function newRoomMetrics() {
|
|
555
|
+
return {
|
|
556
|
+
connections: 0,
|
|
557
|
+
totalConnections: 0,
|
|
558
|
+
ingressBytes: 0,
|
|
559
|
+
egressBytes: 0,
|
|
560
|
+
framesIn: 0,
|
|
561
|
+
framesOut: 0,
|
|
562
|
+
restarts: 0,
|
|
563
|
+
hibernations: 0,
|
|
564
|
+
wakes: 0,
|
|
565
|
+
lastWakeMs: 0,
|
|
566
|
+
maxWakeMs: 0,
|
|
567
|
+
workerElu: 0,
|
|
568
|
+
droppedFramesOnRestart: 0
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
function emptyRoomsByState() {
|
|
572
|
+
return {
|
|
573
|
+
starting: 0,
|
|
574
|
+
running: 0,
|
|
575
|
+
hibernating: 0,
|
|
576
|
+
hibernated: 0,
|
|
577
|
+
waking: 0,
|
|
578
|
+
closed: 0
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
var LogRing = class {
|
|
582
|
+
items = [];
|
|
583
|
+
push(level, args) {
|
|
584
|
+
this.items.push({ at: Date.now(), level, args });
|
|
585
|
+
if (this.items.length > LOG_RING_SIZE) this.items.shift();
|
|
586
|
+
}
|
|
587
|
+
list() {
|
|
588
|
+
return this.items;
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
// ../supervisor/src/rooms.ts
|
|
593
|
+
var WAKE_QUEUE_CAP = 256;
|
|
594
|
+
function relayConfig(limits) {
|
|
595
|
+
return {
|
|
596
|
+
mode: "event",
|
|
597
|
+
tickRate: 0,
|
|
598
|
+
idleMs: limits.relayIdleMs,
|
|
599
|
+
reconnectGraceMs: limits.relayReconnectGraceMs,
|
|
600
|
+
maxClients: limits.relayMaxClients
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
var RoomRecord = class {
|
|
604
|
+
constructor(id, relay, config) {
|
|
605
|
+
this.id = id;
|
|
606
|
+
this.relay = relay;
|
|
607
|
+
this.config = config;
|
|
608
|
+
}
|
|
609
|
+
id;
|
|
610
|
+
relay;
|
|
611
|
+
state = "starting";
|
|
612
|
+
clients = /* @__PURE__ */ new Map();
|
|
613
|
+
worker;
|
|
614
|
+
relayRoom;
|
|
615
|
+
lastTick = 0;
|
|
616
|
+
metrics = newRoomMetrics();
|
|
617
|
+
logs = new LogRing();
|
|
618
|
+
createdAt = Date.now();
|
|
619
|
+
/** Frames that arrived while hibernating/waking, replayed in order after the re-joins. */
|
|
620
|
+
wakeQueue = [];
|
|
621
|
+
/** Frames dropped because the wake queue overflowed. */
|
|
622
|
+
droppedWakeFrames = 0;
|
|
623
|
+
/** Sessions whose grace expired while the room had no worker; `leave` is sent on the next wake. */
|
|
624
|
+
pendingLeaves = /* @__PURE__ */ new Set();
|
|
625
|
+
/** Timestamps of worker restarts, pruned to the last minute (plan §3.3 cap). */
|
|
626
|
+
restarts = [];
|
|
627
|
+
/**
|
|
628
|
+
* The deployment version this room was created under. New rooms take the newest; rooms created
|
|
629
|
+
* before a deploy keep theirs until they idle (per-room drain, plan §3.3).
|
|
630
|
+
*/
|
|
631
|
+
version = 0;
|
|
632
|
+
/** Serialises room-level async work (start / wake / restart / hibernate). */
|
|
633
|
+
transition = Promise.resolve();
|
|
634
|
+
/** Periodic crash-safety snapshot (event-mode bundle rooms) or the relay idle timer. */
|
|
635
|
+
timer;
|
|
636
|
+
config;
|
|
637
|
+
/** Sessions with a live socket that have completed their join. */
|
|
638
|
+
connected() {
|
|
639
|
+
return [...this.clients.values()].filter((s) => s.state === "joined" && s.open);
|
|
640
|
+
}
|
|
641
|
+
connectedCount() {
|
|
642
|
+
return this.connected().length;
|
|
643
|
+
}
|
|
644
|
+
/** Queues a frame for the next wake, dropping the oldest on overflow (plan §3.4). */
|
|
645
|
+
enqueue(frame) {
|
|
646
|
+
if (this.wakeQueue.length >= WAKE_QUEUE_CAP) {
|
|
647
|
+
this.wakeQueue.shift();
|
|
648
|
+
this.droppedWakeFrames++;
|
|
649
|
+
}
|
|
650
|
+
this.wakeQueue.push(frame);
|
|
651
|
+
}
|
|
652
|
+
log(level, ...args) {
|
|
653
|
+
this.logs.push(level, args);
|
|
654
|
+
}
|
|
655
|
+
info() {
|
|
656
|
+
return {
|
|
657
|
+
id: this.id,
|
|
658
|
+
state: this.state,
|
|
659
|
+
relay: this.relay,
|
|
660
|
+
tick: this.lastTick,
|
|
661
|
+
clients: [...this.clients.values()].map((s) => s.info()),
|
|
662
|
+
metrics: { ...this.metrics, workerElu: this.worker?.elu ?? 0 },
|
|
663
|
+
createdAt: this.createdAt
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
var RoomRegistry = class {
|
|
668
|
+
rooms = /* @__PURE__ */ new Map();
|
|
669
|
+
get(id) {
|
|
670
|
+
return this.rooms.get(id);
|
|
671
|
+
}
|
|
672
|
+
has(id) {
|
|
673
|
+
return this.rooms.has(id);
|
|
674
|
+
}
|
|
675
|
+
set(record) {
|
|
676
|
+
this.rooms.set(record.id, record);
|
|
677
|
+
}
|
|
678
|
+
delete(id) {
|
|
679
|
+
this.rooms.delete(id);
|
|
680
|
+
}
|
|
681
|
+
values() {
|
|
682
|
+
return [...this.rooms.values()];
|
|
683
|
+
}
|
|
684
|
+
get size() {
|
|
685
|
+
return this.rooms.size;
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
// ../supervisor/src/session.ts
|
|
690
|
+
import {
|
|
691
|
+
ErrorCode,
|
|
692
|
+
FrameType,
|
|
693
|
+
encodeErrorPayload,
|
|
694
|
+
encodeFrame
|
|
695
|
+
} from "@irtio/protocol";
|
|
696
|
+
var CLOSE_POLICY = 1008;
|
|
697
|
+
var CLOSE_GRACE_MS = 250;
|
|
698
|
+
var Session = class {
|
|
699
|
+
constructor(ws, remoteAddress, origin, framesPerSec, maxBufferedBytes, onSlowConsumer) {
|
|
700
|
+
this.ws = ws;
|
|
701
|
+
this.remoteAddress = remoteAddress;
|
|
702
|
+
this.origin = origin;
|
|
703
|
+
this.maxBufferedBytes = maxBufferedBytes;
|
|
704
|
+
this.onSlowConsumer = onSlowConsumer;
|
|
705
|
+
this.frames = new TokenBucket(framesPerSec * 2, framesPerSec);
|
|
706
|
+
}
|
|
707
|
+
ws;
|
|
708
|
+
remoteAddress;
|
|
709
|
+
origin;
|
|
710
|
+
maxBufferedBytes;
|
|
711
|
+
onSlowConsumer;
|
|
712
|
+
state = "awaiting-hello";
|
|
713
|
+
clientId = "";
|
|
714
|
+
roomId = "";
|
|
715
|
+
/** Set once the HELLO resolved a room. */
|
|
716
|
+
room;
|
|
717
|
+
role = "";
|
|
718
|
+
name = "";
|
|
719
|
+
/** Set when the session was rejected/kicked: no grace window on close. */
|
|
720
|
+
fatal = false;
|
|
721
|
+
/** Slow consumers are closed but keep their grace window (a resume gets a fresh snapshot). */
|
|
722
|
+
slowConsumer = false;
|
|
723
|
+
/** Rate-limit strikes; three ⇒ `E_RATE_LIMITED` fatal (plan §3.2). */
|
|
724
|
+
rateStrikes = 0;
|
|
725
|
+
/** Relay `WRITE`/`CALL` strikes; three ⇒ fatal (plan §3.5). */
|
|
726
|
+
badFrameStrikes = 0;
|
|
727
|
+
missedPongs = 0;
|
|
728
|
+
helloTimer;
|
|
729
|
+
graceTimer;
|
|
730
|
+
frames;
|
|
731
|
+
openedAt = Date.now();
|
|
732
|
+
get open() {
|
|
733
|
+
return this.ws.readyState === this.ws.OPEN;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Sends one already-framed message. Returns the byte count sent (0 when dropped). A client whose
|
|
737
|
+
* `bufferedAmount` is over the limit is closed with `E_SLOW_CONSUMER` — that's exactly what
|
|
738
|
+
* snapshots are for: the resume gets a fresh one.
|
|
739
|
+
*/
|
|
740
|
+
send(bytes) {
|
|
741
|
+
if (!this.open) return 0;
|
|
742
|
+
if (this.ws.bufferedAmount > this.maxBufferedBytes) {
|
|
743
|
+
this.onSlowConsumer(this);
|
|
744
|
+
return 0;
|
|
745
|
+
}
|
|
746
|
+
this.ws.send(bytes, { binary: true });
|
|
747
|
+
return bytes.length;
|
|
748
|
+
}
|
|
749
|
+
sendFrame(type, payload) {
|
|
750
|
+
return this.send(encodeFrame(type, payload));
|
|
751
|
+
}
|
|
752
|
+
sendError(code, message, fatal) {
|
|
753
|
+
this.sendFrame(
|
|
754
|
+
FrameType.ERROR,
|
|
755
|
+
encodeErrorPayload({ code: ErrorCode[code].code, message, fatal })
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
/** ERROR + close (1008). Fatal rejections skip the grace window on the following `close` event. */
|
|
759
|
+
fail(code, message) {
|
|
760
|
+
this.fatal = true;
|
|
761
|
+
this.sendError(code, message, true);
|
|
762
|
+
this.closeSocket(CLOSE_POLICY, code);
|
|
763
|
+
}
|
|
764
|
+
closeSocket(code = CLOSE_POLICY, reason = "") {
|
|
765
|
+
this.clearHelloTimer();
|
|
766
|
+
if (this.ws.readyState === this.ws.CLOSED) return;
|
|
767
|
+
try {
|
|
768
|
+
this.ws.close(code, reason.slice(0, 120));
|
|
769
|
+
} catch {
|
|
770
|
+
}
|
|
771
|
+
const timer = setTimeout(() => {
|
|
772
|
+
try {
|
|
773
|
+
this.ws.terminate();
|
|
774
|
+
} catch {
|
|
775
|
+
}
|
|
776
|
+
}, CLOSE_GRACE_MS);
|
|
777
|
+
timer.unref?.();
|
|
778
|
+
}
|
|
779
|
+
clearHelloTimer() {
|
|
780
|
+
if (this.helloTimer !== void 0) {
|
|
781
|
+
clearTimeout(this.helloTimer);
|
|
782
|
+
this.helloTimer = void 0;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
clearGraceTimer() {
|
|
786
|
+
if (this.graceTimer !== void 0) {
|
|
787
|
+
clearTimeout(this.graceTimer);
|
|
788
|
+
this.graceTimer = void 0;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
info() {
|
|
792
|
+
return {
|
|
793
|
+
clientId: this.clientId,
|
|
794
|
+
roomId: this.roomId,
|
|
795
|
+
role: this.role,
|
|
796
|
+
name: this.name,
|
|
797
|
+
state: this.state,
|
|
798
|
+
remoteAddress: this.remoteAddress,
|
|
799
|
+
origin: this.origin
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
// ../supervisor/src/worker-host.ts
|
|
805
|
+
import { pathToFileURL } from "url";
|
|
806
|
+
import { Worker } from "worker_threads";
|
|
807
|
+
var ELU_SAMPLE_MS = 5e3;
|
|
808
|
+
function transferList(msg) {
|
|
809
|
+
const out = [];
|
|
810
|
+
for (const v of Object.values(msg)) {
|
|
811
|
+
if (v instanceof Uint8Array && v.buffer instanceof ArrayBuffer) out.push(v.buffer);
|
|
812
|
+
}
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
function toEntryUrl(entry) {
|
|
816
|
+
return entry.startsWith("file:") ? new URL(entry) : pathToFileURL(entry);
|
|
817
|
+
}
|
|
818
|
+
var WorkerHost = class {
|
|
819
|
+
constructor(entry, limits, handlers) {
|
|
820
|
+
this.handlers = handlers;
|
|
821
|
+
this.worker = new Worker(toEntryUrl(entry), {
|
|
822
|
+
resourceLimits: {
|
|
823
|
+
maxOldGenerationSizeMb: limits.workerMaxOldGenMb,
|
|
824
|
+
maxYoungGenerationSizeMb: limits.workerMaxYoungGenMb
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
this.worker.unref();
|
|
828
|
+
this.worker.on("message", (msg) => this.onMessage(msg));
|
|
829
|
+
this.worker.on("error", (err) => this.die(`worker error: ${err.stack ?? err.message}`));
|
|
830
|
+
this.worker.on("exit", (code) => this.die(`worker exited with code ${code}`));
|
|
831
|
+
try {
|
|
832
|
+
this.lastElu = this.worker.performance.eventLoopUtilization();
|
|
833
|
+
} catch {
|
|
834
|
+
}
|
|
835
|
+
this.eluTimer = setInterval(() => this.sampleElu(), ELU_SAMPLE_MS);
|
|
836
|
+
this.eluTimer.unref?.();
|
|
837
|
+
}
|
|
838
|
+
handlers;
|
|
839
|
+
worker;
|
|
840
|
+
pending = /* @__PURE__ */ new Map();
|
|
841
|
+
eluTimer;
|
|
842
|
+
lastElu;
|
|
843
|
+
reqId = 0;
|
|
844
|
+
dead = false;
|
|
845
|
+
/** Set before an intentional `terminate()` so the exit isn't reported as a crash. */
|
|
846
|
+
disposed = false;
|
|
847
|
+
/** Sampled event-loop utilization, 0..1. */
|
|
848
|
+
elu = 0;
|
|
849
|
+
sampleElu() {
|
|
850
|
+
if (this.dead || this.disposed) return;
|
|
851
|
+
try {
|
|
852
|
+
const next = this.worker.performance.eventLoopUtilization();
|
|
853
|
+
if (this.lastElu) {
|
|
854
|
+
this.elu = this.worker.performance.eventLoopUtilization(next, this.lastElu).utilization;
|
|
855
|
+
}
|
|
856
|
+
this.lastElu = next;
|
|
857
|
+
} catch {
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
onMessage(msg) {
|
|
861
|
+
if ((msg.t === "serialized" || msg.t === "inspected") && this.pending.has(msg.reqId)) {
|
|
862
|
+
const resolve2 = this.pending.get(msg.reqId);
|
|
863
|
+
this.pending.delete(msg.reqId);
|
|
864
|
+
resolve2?.(msg);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (msg.t === "crashed") {
|
|
868
|
+
this.handlers.onMessage(msg);
|
|
869
|
+
this.die(`room crashed: ${msg.reason}`);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
this.handlers.onMessage(msg);
|
|
873
|
+
}
|
|
874
|
+
die(reason) {
|
|
875
|
+
if (this.dead) return;
|
|
876
|
+
this.dead = true;
|
|
877
|
+
clearInterval(this.eluTimer);
|
|
878
|
+
for (const [, resolve2] of this.pending) {
|
|
879
|
+
resolve2({ t: "initFailed", reason });
|
|
880
|
+
}
|
|
881
|
+
this.pending.clear();
|
|
882
|
+
if (this.disposed) return;
|
|
883
|
+
this.handlers.onDead(reason);
|
|
884
|
+
}
|
|
885
|
+
get alive() {
|
|
886
|
+
return !this.dead && !this.disposed;
|
|
887
|
+
}
|
|
888
|
+
post(msg) {
|
|
889
|
+
if (this.dead || this.disposed) return;
|
|
890
|
+
try {
|
|
891
|
+
this.worker.postMessage(msg, transferList(msg));
|
|
892
|
+
} catch (err) {
|
|
893
|
+
this.die(`postMessage failed: ${String(err)}`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
request(make, timeoutMs) {
|
|
897
|
+
if (this.dead || this.disposed) return Promise.resolve(void 0);
|
|
898
|
+
const reqId = ++this.reqId;
|
|
899
|
+
return new Promise((resolve2) => {
|
|
900
|
+
const timer = setTimeout(() => {
|
|
901
|
+
this.pending.delete(reqId);
|
|
902
|
+
resolve2(void 0);
|
|
903
|
+
}, timeoutMs);
|
|
904
|
+
timer.unref?.();
|
|
905
|
+
this.pending.set(reqId, (msg) => {
|
|
906
|
+
clearTimeout(timer);
|
|
907
|
+
resolve2(msg);
|
|
908
|
+
});
|
|
909
|
+
this.post(make(reqId));
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
/** Hibernation/crash-safety snapshot. `undefined` when the worker died or timed out. */
|
|
913
|
+
async serialize(timeoutMs = 1e4) {
|
|
914
|
+
const msg = await this.request(
|
|
915
|
+
(reqId) => ({ t: "serialize", reqId }),
|
|
916
|
+
timeoutMs
|
|
917
|
+
);
|
|
918
|
+
return msg?.t === "serialized" ? msg.bytes : void 0;
|
|
919
|
+
}
|
|
920
|
+
/** Live state + recent events for the admin API. */
|
|
921
|
+
async inspect(timeoutMs = 5e3) {
|
|
922
|
+
const msg = await this.request(
|
|
923
|
+
(reqId) => ({ t: "inspect", reqId }),
|
|
924
|
+
timeoutMs
|
|
925
|
+
);
|
|
926
|
+
return msg?.t === "inspected" ? msg : void 0;
|
|
927
|
+
}
|
|
928
|
+
/** Intentional shutdown: `onDead` is not fired. */
|
|
929
|
+
async terminate() {
|
|
930
|
+
if (this.disposed) return;
|
|
931
|
+
this.disposed = true;
|
|
932
|
+
clearInterval(this.eluTimer);
|
|
933
|
+
try {
|
|
934
|
+
await this.worker.terminate();
|
|
935
|
+
} catch {
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
/** Test/ops hook behind `Supervisor.killWorker`: a hard kill reported as a crash. */
|
|
939
|
+
async kill() {
|
|
940
|
+
try {
|
|
941
|
+
await this.worker.terminate();
|
|
942
|
+
} catch {
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
// ../supervisor/src/server.ts
|
|
948
|
+
var WS_PING_MS = 2e4;
|
|
949
|
+
var WS_PING_MISSES = 2;
|
|
950
|
+
var MAX_STRIKES = 3;
|
|
951
|
+
var JOIN_TIMEOUT_MS = 15e3;
|
|
952
|
+
function defaultSetSystemTime(epochMs) {
|
|
953
|
+
if (process.platform !== "linux") return Promise.resolve(false);
|
|
954
|
+
return new Promise((resolve2, reject) => {
|
|
955
|
+
execFile("date", ["-u", "-s", `@${Math.floor(epochMs / 1e3)}`], (err) => {
|
|
956
|
+
if (err) reject(err);
|
|
957
|
+
else resolve2(true);
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
function ownedCopy(data) {
|
|
962
|
+
if (Array.isArray(data)) {
|
|
963
|
+
const total = data.reduce((n, b) => n + b.byteLength, 0);
|
|
964
|
+
const out2 = new Uint8Array(total);
|
|
965
|
+
let at = 0;
|
|
966
|
+
for (const b of data) {
|
|
967
|
+
out2.set(new Uint8Array(b.buffer, b.byteOffset, b.byteLength), at);
|
|
968
|
+
at += b.byteLength;
|
|
969
|
+
}
|
|
970
|
+
return out2;
|
|
971
|
+
}
|
|
972
|
+
const view = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
973
|
+
const out = new Uint8Array(view.byteLength);
|
|
974
|
+
out.set(view);
|
|
975
|
+
return out;
|
|
976
|
+
}
|
|
977
|
+
var SupervisorImpl = class {
|
|
978
|
+
constructor(config) {
|
|
979
|
+
this.config = config;
|
|
980
|
+
this.limits = { ...DEFAULT_LIMITS, ...config.limits };
|
|
981
|
+
this.store = config.store;
|
|
982
|
+
this.resumeSecret = config.resumeSecret ?? randomBytes(32).toString("base64url");
|
|
983
|
+
if (config.resumeSecret === void 0) {
|
|
984
|
+
this.log("warn", "no resumeSecret configured: resume tokens die with this process");
|
|
985
|
+
}
|
|
986
|
+
this.ipBuckets = new BucketMap(
|
|
987
|
+
() => new TokenBucket(
|
|
988
|
+
this.limits.connectionsPerIpPerMin,
|
|
989
|
+
this.limits.connectionsPerIpPerMin / 60
|
|
990
|
+
)
|
|
991
|
+
);
|
|
992
|
+
this.helloBuckets = new BucketMap(
|
|
993
|
+
() => new TokenBucket(this.limits.hellosPerMin, this.limits.hellosPerMin / 60)
|
|
994
|
+
);
|
|
995
|
+
this.http = createServer((req, res) => {
|
|
996
|
+
try {
|
|
997
|
+
if (config.httpHandler?.(req, res) === true) return;
|
|
998
|
+
} catch (err) {
|
|
999
|
+
this.log("error", "httpHandler threw", err);
|
|
1000
|
+
if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain" });
|
|
1001
|
+
res.end("internal error");
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (req.url === "/healthz") {
|
|
1005
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
1006
|
+
res.end("ok");
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (this.serveAdmin(req, res)) return;
|
|
1010
|
+
res.writeHead(404).end();
|
|
1011
|
+
});
|
|
1012
|
+
this.wss = new WebSocketServer({ server: this.http });
|
|
1013
|
+
this.wss.on("connection", (ws, req) => {
|
|
1014
|
+
this.onConnection(ws, {
|
|
1015
|
+
origin: header(req.headers.origin),
|
|
1016
|
+
routerOrigin: header(req.headers["x-irt-origin"]),
|
|
1017
|
+
forwardedFor: header(req.headers["x-forwarded-for"]),
|
|
1018
|
+
routerSecret: header(req.headers["x-irt-router-secret"]),
|
|
1019
|
+
remoteAddress: req.socket.remoteAddress ?? ""
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
this.readyPromise = this.boot();
|
|
1023
|
+
}
|
|
1024
|
+
config;
|
|
1025
|
+
registry = new RoomRegistry();
|
|
1026
|
+
sessions = /* @__PURE__ */ new Set();
|
|
1027
|
+
limits;
|
|
1028
|
+
store;
|
|
1029
|
+
processMetrics = newProcessMetrics();
|
|
1030
|
+
/** The newest runnable deployment; every new room is created under it. */
|
|
1031
|
+
bundle;
|
|
1032
|
+
schemaHash;
|
|
1033
|
+
/** Every deployment the tenant knows, by version (runnable or schema-only). */
|
|
1034
|
+
deployments = /* @__PURE__ */ new Map();
|
|
1035
|
+
port = 0;
|
|
1036
|
+
http;
|
|
1037
|
+
wss;
|
|
1038
|
+
readyPromise;
|
|
1039
|
+
ipBuckets;
|
|
1040
|
+
helloBuckets;
|
|
1041
|
+
resumeSecret;
|
|
1042
|
+
clientIds = /* @__PURE__ */ new Set();
|
|
1043
|
+
joinWaiters = /* @__PURE__ */ new Map();
|
|
1044
|
+
readyWaiters = /* @__PURE__ */ new Map();
|
|
1045
|
+
creating = /* @__PURE__ */ new Map();
|
|
1046
|
+
workerEntry = "";
|
|
1047
|
+
closing = false;
|
|
1048
|
+
idleSince = 0;
|
|
1049
|
+
idleFired = false;
|
|
1050
|
+
/** True only once the tenant-idle flush has *completed* — `/admin/idle` reports idle from this,
|
|
1051
|
+
* so the host agent never snapshots a VM whose rooms are still mid-flush to the store. */
|
|
1052
|
+
idleFlushed = false;
|
|
1053
|
+
idleTimer;
|
|
1054
|
+
pingTimer;
|
|
1055
|
+
// -------------------------------------------------------------------------
|
|
1056
|
+
// Boot / shutdown
|
|
1057
|
+
// -------------------------------------------------------------------------
|
|
1058
|
+
async boot() {
|
|
1059
|
+
for (const ref of bundleRefsOf(this.config)) {
|
|
1060
|
+
await this.registerDeployment(ref);
|
|
1061
|
+
}
|
|
1062
|
+
this.bundle = this.newestBundle();
|
|
1063
|
+
this.schemaHash = this.bundle?.schemaHash;
|
|
1064
|
+
this.workerEntry = this.config.workerEntry ?? resolveWorkerEntry();
|
|
1065
|
+
await new Promise((resolve2, reject) => {
|
|
1066
|
+
this.http.once("error", reject);
|
|
1067
|
+
this.http.listen(this.config.port, this.config.host ?? "127.0.0.1", () => {
|
|
1068
|
+
const address = this.http.address();
|
|
1069
|
+
this.port = typeof address === "object" && address !== null ? address.port : this.config.port;
|
|
1070
|
+
resolve2();
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
this.pingTimer = setInterval(() => this.keepalive(), WS_PING_MS);
|
|
1074
|
+
this.pingTimer.unref?.();
|
|
1075
|
+
const tenantIdleMs = this.config.tenantIdleMs ?? 3e5;
|
|
1076
|
+
if (tenantIdleMs > 0) {
|
|
1077
|
+
const every = Math.min(1e3, Math.max(50, Math.floor(tenantIdleMs / 4)));
|
|
1078
|
+
this.idleTimer = setInterval(() => this.checkTenantIdle(tenantIdleMs), every);
|
|
1079
|
+
this.idleTimer.unref?.();
|
|
1080
|
+
}
|
|
1081
|
+
this.log(
|
|
1082
|
+
"info",
|
|
1083
|
+
`supervisor listening on ${this.port} (${this.bundle ? "bundle" : "relay"} tenant)`
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
ready() {
|
|
1087
|
+
return this.readyPromise;
|
|
1088
|
+
}
|
|
1089
|
+
async flushAll() {
|
|
1090
|
+
await flushAll(this);
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* `irtio dev`'s hot swap (plan §3.7): load the new bundle, then close every room with
|
|
1094
|
+
* `E_ROOM_CLOSED` and reset. Load-first means a broken rebuild rejects without disturbing the
|
|
1095
|
+
* rooms that are still serving the old code.
|
|
1096
|
+
*/
|
|
1097
|
+
async reloadBundle(bundlePath) {
|
|
1098
|
+
const loaded = await loadBundle(bundlePath, this.deploymentVersion);
|
|
1099
|
+
for (const room of this.registry.values()) {
|
|
1100
|
+
room.wakeQueue.length = 0;
|
|
1101
|
+
room.droppedWakeFrames = 0;
|
|
1102
|
+
room.pendingLeaves.clear();
|
|
1103
|
+
this.closeRoom(room, "E_ROOM_CLOSED", "room code changed \u2014 reload");
|
|
1104
|
+
}
|
|
1105
|
+
this.deployments.set(loaded.version, {
|
|
1106
|
+
version: loaded.version,
|
|
1107
|
+
schemaJson: loaded.schemaJson,
|
|
1108
|
+
bundle: loaded
|
|
1109
|
+
});
|
|
1110
|
+
this.bundle = loaded;
|
|
1111
|
+
this.schemaHash = loaded.schemaHash;
|
|
1112
|
+
this.log("info", `bundle reloaded from ${bundlePath}`);
|
|
1113
|
+
}
|
|
1114
|
+
async close() {
|
|
1115
|
+
if (this.closing) return;
|
|
1116
|
+
this.closing = true;
|
|
1117
|
+
if (this.idleTimer) clearInterval(this.idleTimer);
|
|
1118
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
1119
|
+
try {
|
|
1120
|
+
await flushAll(this);
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
this.log("error", "flush on close failed", err);
|
|
1123
|
+
}
|
|
1124
|
+
for (const session of [...this.sessions]) {
|
|
1125
|
+
session.clearHelloTimer();
|
|
1126
|
+
session.clearGraceTimer();
|
|
1127
|
+
try {
|
|
1128
|
+
session.ws.terminate();
|
|
1129
|
+
} catch {
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
this.sessions.clear();
|
|
1133
|
+
for (const room of this.registry.values()) {
|
|
1134
|
+
this.clearRoomTimer(room);
|
|
1135
|
+
await room.worker?.terminate();
|
|
1136
|
+
room.worker = void 0;
|
|
1137
|
+
}
|
|
1138
|
+
await new Promise((resolve2) => this.wss.close(() => resolve2()));
|
|
1139
|
+
await new Promise((resolve2) => this.http.close(() => resolve2()));
|
|
1140
|
+
}
|
|
1141
|
+
// -------------------------------------------------------------------------
|
|
1142
|
+
// Logging
|
|
1143
|
+
// -------------------------------------------------------------------------
|
|
1144
|
+
log(level, ...args) {
|
|
1145
|
+
if (this.config.log) this.config.log(level, ...args);
|
|
1146
|
+
else console[level === "info" ? "log" : level]("[irtio]", ...args);
|
|
1147
|
+
}
|
|
1148
|
+
roomLog(room, level, ...args) {
|
|
1149
|
+
room.log(level, ...args);
|
|
1150
|
+
this.log(level, `[${room.id}]`, ...args);
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Week-5 snapshot keys carry the deployment version (`@v3`); version `0` keeps the week-3 key
|
|
1154
|
+
* shape, so a dev tenant's existing snapshots stay readable.
|
|
1155
|
+
*/
|
|
1156
|
+
/**
|
|
1157
|
+
* The read-only admin API the host agent polls from outside the VM (week 5). It exists only
|
|
1158
|
+
* when `adminToken` is configured, and every route is a projection of `SupervisorAdmin` — the
|
|
1159
|
+
* agent must not be able to *change* anything about a tenant it is shipping telemetry for.
|
|
1160
|
+
*/
|
|
1161
|
+
serveAdmin(req, res) {
|
|
1162
|
+
const token = this.config.adminToken;
|
|
1163
|
+
if (token === void 0 || !req.url?.startsWith("/admin/")) return false;
|
|
1164
|
+
const url = new URL(req.url, "http://tenant");
|
|
1165
|
+
const offered = (req.headers.authorization ?? "").replace(/^Bearer /, "");
|
|
1166
|
+
const a = Buffer.from(offered);
|
|
1167
|
+
const b = Buffer.from(token);
|
|
1168
|
+
if (a.length !== b.length || !timingSafeEqual2(a, b)) {
|
|
1169
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
1170
|
+
res.end('{"error":"unauthorized"}');
|
|
1171
|
+
return true;
|
|
1172
|
+
}
|
|
1173
|
+
const json = (body) => {
|
|
1174
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1175
|
+
res.end(JSON.stringify(body));
|
|
1176
|
+
};
|
|
1177
|
+
switch (url.pathname) {
|
|
1178
|
+
// The idle signal the host agent polls (week-5 report §7.1): placed tenants run with
|
|
1179
|
+
// `IRT_EXIT_WHEN_IDLE=0`, so instead of the guest powering itself off it reports idle here
|
|
1180
|
+
// and the agent pauses + snapshots the still-running VM before stopping it. `idle` goes
|
|
1181
|
+
// true only after the tenant-idle flush completed AND no socket has connected since.
|
|
1182
|
+
case "/admin/idle":
|
|
1183
|
+
json({
|
|
1184
|
+
idle: this.idleFlushed && this.sessions.size === 0,
|
|
1185
|
+
sockets: this.sessions.size,
|
|
1186
|
+
idleForMs: this.idleSince > 0 ? Date.now() - this.idleSince : 0
|
|
1187
|
+
});
|
|
1188
|
+
return true;
|
|
1189
|
+
case "/admin/rooms":
|
|
1190
|
+
json({ rooms: this.rooms(), deploymentVersion: this.deploymentVersion });
|
|
1191
|
+
return true;
|
|
1192
|
+
case "/admin/metrics":
|
|
1193
|
+
json({
|
|
1194
|
+
at: Date.now(),
|
|
1195
|
+
metrics: this.metrics(),
|
|
1196
|
+
rooms: this.rooms().map((r) => r.metrics)
|
|
1197
|
+
});
|
|
1198
|
+
return true;
|
|
1199
|
+
// D31 (week 7): the ONE write on this otherwise read-only surface, and a deliberate
|
|
1200
|
+
// exception to its "the agent must not be able to change anything" rule: after a
|
|
1201
|
+
// VM-snapshot restore the guest's wall clock is frozen at snapshot time (week-6 report
|
|
1202
|
+
// §7.2), and the host agent — the only caller who can know the real time the moment the
|
|
1203
|
+
// restore completes — POSTs it here before any join reaches a room. `room.now` is
|
|
1204
|
+
// additionally monotonic-clamped in the runtime, so handlers never see time go backwards
|
|
1205
|
+
// even mid-correction.
|
|
1206
|
+
case "/admin/resume": {
|
|
1207
|
+
if (req.method !== "POST") {
|
|
1208
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
1209
|
+
res.end('{"error":"POST only"}');
|
|
1210
|
+
return true;
|
|
1211
|
+
}
|
|
1212
|
+
let body = "";
|
|
1213
|
+
req.on("data", (d) => {
|
|
1214
|
+
body += d.toString();
|
|
1215
|
+
});
|
|
1216
|
+
req.on("end", () => {
|
|
1217
|
+
void (async () => {
|
|
1218
|
+
const beforeMs = Date.now();
|
|
1219
|
+
let epochMs = Number.NaN;
|
|
1220
|
+
try {
|
|
1221
|
+
epochMs = Number(JSON.parse(body || "{}").epochMs);
|
|
1222
|
+
} catch {
|
|
1223
|
+
}
|
|
1224
|
+
if (!Number.isFinite(epochMs) || epochMs <= 0) {
|
|
1225
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
1226
|
+
res.end('{"error":"body must be {\\"epochMs\\": <ms>}"}');
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
let applied = false;
|
|
1230
|
+
try {
|
|
1231
|
+
applied = await (this.config.setSystemTime ?? defaultSetSystemTime)(epochMs);
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
this.log("warn", `resume clock set failed: ${String(err)}`);
|
|
1234
|
+
}
|
|
1235
|
+
if (applied) {
|
|
1236
|
+
this.log(
|
|
1237
|
+
"info",
|
|
1238
|
+
`resume: clock set to ${new Date(epochMs).toISOString()} (was off by ${epochMs - beforeMs} ms)`
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
json({ ok: true, applied, beforeMs, afterMs: Date.now() });
|
|
1242
|
+
})();
|
|
1243
|
+
});
|
|
1244
|
+
return true;
|
|
1245
|
+
}
|
|
1246
|
+
case "/admin/logs": {
|
|
1247
|
+
const since = Number(url.searchParams.get("since") ?? 0) || 0;
|
|
1248
|
+
const limit = Math.min(Number(url.searchParams.get("limit") ?? 200) || 200, 1e3);
|
|
1249
|
+
const entries = [];
|
|
1250
|
+
for (const room of this.registry.values()) {
|
|
1251
|
+
for (const entry of this.logs(room.id)) {
|
|
1252
|
+
if (entry.at <= since) continue;
|
|
1253
|
+
entries.push({
|
|
1254
|
+
at: entry.at,
|
|
1255
|
+
level: entry.level,
|
|
1256
|
+
roomId: room.id,
|
|
1257
|
+
message: entry.args.map((x) => typeof x === "string" ? x : String(x)).join(" ")
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
entries.sort((x, y) => x.at - y.at);
|
|
1262
|
+
json({ entries: entries.slice(-limit) });
|
|
1263
|
+
return true;
|
|
1264
|
+
}
|
|
1265
|
+
default:
|
|
1266
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
1267
|
+
res.end('{"error":"not found"}');
|
|
1268
|
+
return true;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
storeKey(room, version) {
|
|
1272
|
+
const id = typeof room === "string" ? room : room.id;
|
|
1273
|
+
const v = version ?? (typeof room === "string" ? 0 : room.version);
|
|
1274
|
+
const base = `${this.config.projectId}/${id}`;
|
|
1275
|
+
return v > 0 ? `${base}@v${v}` : base;
|
|
1276
|
+
}
|
|
1277
|
+
/** The newest deployment that can actually run a room. */
|
|
1278
|
+
newestBundle() {
|
|
1279
|
+
let best;
|
|
1280
|
+
for (const d of this.deployments.values()) {
|
|
1281
|
+
if (d.bundle && (!best || d.bundle.version > best.version)) best = d.bundle;
|
|
1282
|
+
}
|
|
1283
|
+
return best;
|
|
1284
|
+
}
|
|
1285
|
+
get deploymentVersion() {
|
|
1286
|
+
return this.bundle?.version ?? 0;
|
|
1287
|
+
}
|
|
1288
|
+
bundleVersions() {
|
|
1289
|
+
return [...this.deployments.values()].filter((d) => d.bundle !== void 0).map((d) => d.version).sort((a, b) => b - a);
|
|
1290
|
+
}
|
|
1291
|
+
async registerDeployment(ref) {
|
|
1292
|
+
const migrationUrl = ref.migrationPath !== void 0 ? toFileUrl(ref.migrationPath) : void 0;
|
|
1293
|
+
const bundle = ref.bundlePath !== void 0 ? await loadBundle(ref.bundlePath, ref.version) : void 0;
|
|
1294
|
+
const schemaJson = bundle?.schemaJson ?? ref.schemaJson;
|
|
1295
|
+
if (schemaJson === void 0) {
|
|
1296
|
+
throw new Error(
|
|
1297
|
+
`irtio: deployment v${ref.version} has neither a bundle nor a schemaJson to decode its snapshots with`
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
this.deployments.set(ref.version, {
|
|
1301
|
+
version: ref.version,
|
|
1302
|
+
schemaJson,
|
|
1303
|
+
...migrationUrl !== void 0 ? { migrationUrl } : {},
|
|
1304
|
+
...bundle !== void 0 ? { bundle } : {}
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
/** Registers a newer deployment. Rooms already running keep theirs and drain (plan §3.3). */
|
|
1308
|
+
async addBundle(ref) {
|
|
1309
|
+
if (ref.bundlePath === void 0 && ref.schemaJson === void 0) {
|
|
1310
|
+
throw new Error("addBundle: a deployment needs a bundlePath or a schemaJson");
|
|
1311
|
+
}
|
|
1312
|
+
await this.registerDeployment(ref);
|
|
1313
|
+
const newest = this.newestBundle();
|
|
1314
|
+
if (newest) {
|
|
1315
|
+
this.bundle = newest;
|
|
1316
|
+
this.schemaHash = newest.schemaHash;
|
|
1317
|
+
}
|
|
1318
|
+
const draining = this.registry.values().filter((r) => !r.relay && r.state !== "closed" && r.version !== this.deploymentVersion);
|
|
1319
|
+
this.log(
|
|
1320
|
+
"info",
|
|
1321
|
+
`deployment v${ref.version} registered; new rooms use v${this.deploymentVersion}, ${draining.length} room(s) draining on older versions`
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* The newest snapshot for a room across every version prefix, plus the version it was written
|
|
1326
|
+
* under. Week-3 keys with no `@v` suffix read as version 0.
|
|
1327
|
+
*/
|
|
1328
|
+
async findSnapshot(roomId) {
|
|
1329
|
+
const base = `${this.config.projectId}/${roomId}`;
|
|
1330
|
+
let best;
|
|
1331
|
+
for (const key of await this.store.list(base)) {
|
|
1332
|
+
let version;
|
|
1333
|
+
if (key === base) version = 0;
|
|
1334
|
+
else if (key.startsWith(`${base}@v`)) {
|
|
1335
|
+
version = Number(key.slice(base.length + 2));
|
|
1336
|
+
if (!Number.isInteger(version)) continue;
|
|
1337
|
+
} else continue;
|
|
1338
|
+
if (!best || version > best.version) best = { key, version };
|
|
1339
|
+
}
|
|
1340
|
+
if (!best) return void 0;
|
|
1341
|
+
const bytes = await this.store.get(best.key);
|
|
1342
|
+
return bytes ? { bytes, version: best.version } : void 0;
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* The `{version, schema, migration}` chain a worker needs to bring a snapshot written under
|
|
1346
|
+
* `from` up to `to`. `undefined` when there is nothing to do; throws when a version in between
|
|
1347
|
+
* is unknown, which would otherwise mean decoding bytes with the wrong schema.
|
|
1348
|
+
*/
|
|
1349
|
+
migrationChain(from, to) {
|
|
1350
|
+
if (from >= to) return void 0;
|
|
1351
|
+
const steps = [];
|
|
1352
|
+
for (const d of [...this.deployments.values()].sort((a, b) => a.version - b.version)) {
|
|
1353
|
+
if (d.version < from || d.version > to) continue;
|
|
1354
|
+
steps.push({
|
|
1355
|
+
version: d.version,
|
|
1356
|
+
schemaJson: d.schemaJson,
|
|
1357
|
+
...d.migrationUrl !== void 0 && d.version > from ? { migrationUrl: d.migrationUrl } : {}
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
if (steps[0]?.version !== from) {
|
|
1361
|
+
throw new Error(
|
|
1362
|
+
`irtio: no stored schema for deployment v${from}; its snapshots cannot be migrated`
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
if (steps[steps.length - 1]?.version !== to) {
|
|
1366
|
+
throw new Error(`irtio: deployment v${to} is missing from the migration chain`);
|
|
1367
|
+
}
|
|
1368
|
+
return steps;
|
|
1369
|
+
}
|
|
1370
|
+
// -------------------------------------------------------------------------
|
|
1371
|
+
// Sockets
|
|
1372
|
+
// -------------------------------------------------------------------------
|
|
1373
|
+
onConnection(ws, meta) {
|
|
1374
|
+
const trusted = this.config.routerSecret !== void 0 && meta.routerSecret === this.config.routerSecret;
|
|
1375
|
+
const origin = trusted ? meta.routerOrigin : meta.origin;
|
|
1376
|
+
const remoteAddress = trusted ? meta.forwardedFor?.split(",")[0]?.trim() ?? meta.remoteAddress : meta.remoteAddress;
|
|
1377
|
+
const session = new Session(
|
|
1378
|
+
ws,
|
|
1379
|
+
remoteAddress,
|
|
1380
|
+
origin,
|
|
1381
|
+
this.limits.framesPerSec,
|
|
1382
|
+
this.limits.maxBufferedBytes,
|
|
1383
|
+
(s) => this.onSlowConsumer(s)
|
|
1384
|
+
);
|
|
1385
|
+
this.sessions.add(session);
|
|
1386
|
+
this.idleSince = 0;
|
|
1387
|
+
this.idleFired = false;
|
|
1388
|
+
this.idleFlushed = false;
|
|
1389
|
+
ws.on("close", () => this.onSocketClose(session));
|
|
1390
|
+
ws.on("error", () => {
|
|
1391
|
+
});
|
|
1392
|
+
ws.on("pong", () => {
|
|
1393
|
+
session.missedPongs = 0;
|
|
1394
|
+
});
|
|
1395
|
+
ws.on("message", (data) => this.onSocketMessage(session, data));
|
|
1396
|
+
if (!originAllowed(this.config.origins, this.config.allowNoOrigin !== false, origin)) {
|
|
1397
|
+
session.fail("E_ORIGIN", formatError("E_ORIGIN", { origin: origin ?? "(none)" }));
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
if (!this.ipBuckets.take(remoteAddress)) {
|
|
1401
|
+
session.fail("E_RATE_LIMITED", "too many connections from this address");
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
session.helloTimer = setTimeout(() => {
|
|
1405
|
+
session.helloTimer = void 0;
|
|
1406
|
+
if (session.state === "awaiting-hello") {
|
|
1407
|
+
session.fail("E_AUTH", `no HELLO within ${this.limits.helloTimeoutMs} ms`);
|
|
1408
|
+
}
|
|
1409
|
+
}, this.limits.helloTimeoutMs);
|
|
1410
|
+
session.helloTimer.unref?.();
|
|
1411
|
+
}
|
|
1412
|
+
onSocketMessage(session, data) {
|
|
1413
|
+
if (session.fatal || session.state === "gone") return;
|
|
1414
|
+
const bytes = ownedCopy(data);
|
|
1415
|
+
if (session.state === "awaiting-hello") {
|
|
1416
|
+
let payload;
|
|
1417
|
+
try {
|
|
1418
|
+
const frame = decodeFrame(bytes);
|
|
1419
|
+
if (frame.type !== FrameType2.HELLO) {
|
|
1420
|
+
session.fail("E_BAD_FRAME", formatError("E_BAD_FRAME", { frame: frame.type }));
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
payload = frame.payload;
|
|
1424
|
+
} catch {
|
|
1425
|
+
session.fail("E_BAD_FRAME", "malformed frame before HELLO");
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
void this.handleHello(session, payload);
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
this.routeFrame(session, bytes);
|
|
1432
|
+
}
|
|
1433
|
+
onSlowConsumer(session) {
|
|
1434
|
+
if (session.slowConsumer) return;
|
|
1435
|
+
session.slowConsumer = true;
|
|
1436
|
+
try {
|
|
1437
|
+
session.ws.send(
|
|
1438
|
+
encodeFrame2(
|
|
1439
|
+
FrameType2.ERROR,
|
|
1440
|
+
encodeErrorPayload2({
|
|
1441
|
+
code: ErrorCode2.E_SLOW_CONSUMER.code,
|
|
1442
|
+
message: formatError("E_SLOW_CONSUMER"),
|
|
1443
|
+
fatal: true
|
|
1444
|
+
})
|
|
1445
|
+
),
|
|
1446
|
+
{ binary: true }
|
|
1447
|
+
);
|
|
1448
|
+
} catch {
|
|
1449
|
+
}
|
|
1450
|
+
if (session.room)
|
|
1451
|
+
this.roomLog(session.room, "warn", `slow consumer ${session.clientId}: closing`);
|
|
1452
|
+
session.closeSocket(1008, "E_SLOW_CONSUMER");
|
|
1453
|
+
}
|
|
1454
|
+
keepalive() {
|
|
1455
|
+
for (const session of this.sessions) {
|
|
1456
|
+
if (!session.open) continue;
|
|
1457
|
+
if (session.missedPongs >= WS_PING_MISSES) {
|
|
1458
|
+
try {
|
|
1459
|
+
session.ws.terminate();
|
|
1460
|
+
} catch {
|
|
1461
|
+
}
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1464
|
+
session.missedPongs++;
|
|
1465
|
+
try {
|
|
1466
|
+
session.ws.ping();
|
|
1467
|
+
} catch {
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
this.ipBuckets.sweep();
|
|
1471
|
+
this.helloBuckets.sweep();
|
|
1472
|
+
}
|
|
1473
|
+
onSocketClose(session) {
|
|
1474
|
+
this.sessions.delete(session);
|
|
1475
|
+
session.clearHelloTimer();
|
|
1476
|
+
const room = session.room;
|
|
1477
|
+
if (session.state === "gone") return;
|
|
1478
|
+
if (!room || session.state === "awaiting-hello" || session.state === "joining") {
|
|
1479
|
+
session.state = "gone";
|
|
1480
|
+
if (room && room.clients.get(session.clientId) === session) {
|
|
1481
|
+
room.clients.delete(session.clientId);
|
|
1482
|
+
}
|
|
1483
|
+
this.clientIds.delete(session.clientId);
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
if (session.state !== "joined") return;
|
|
1487
|
+
session.state = "disconnected";
|
|
1488
|
+
room.metrics.connections = Math.max(0, room.metrics.connections - 1);
|
|
1489
|
+
if (session.fatal && !session.slowConsumer) {
|
|
1490
|
+
this.expireSession(session, "kicked");
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
if (!room.relay && room.state === "running" && room.worker) {
|
|
1494
|
+
room.worker.post({ t: "disconnected", clientId: session.clientId });
|
|
1495
|
+
}
|
|
1496
|
+
if (room.relay && room.relayRoom) {
|
|
1497
|
+
const delta = room.relayRoom.setConnected(session.clientId, false);
|
|
1498
|
+
if (delta) {
|
|
1499
|
+
room.lastTick = room.relayRoom.tick;
|
|
1500
|
+
this.broadcast(room, encodeFrame2(FrameType2.DELTA, delta));
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
session.graceTimer = setTimeout(
|
|
1504
|
+
() => this.expireSession(session, "timeout"),
|
|
1505
|
+
room.config.reconnectGraceMs
|
|
1506
|
+
);
|
|
1507
|
+
session.graceTimer.unref?.();
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* The grace window ran out, the client was kicked, or (week 7, D-LEAVE) it sent `LEAVE` and
|
|
1511
|
+
* skips the window entirely. A departure with no `LEAVE` frame — a killed socket, a crash — still
|
|
1512
|
+
* goes through the grace window and lands here as `'timeout'`.
|
|
1513
|
+
*/
|
|
1514
|
+
expireSession(session, reason) {
|
|
1515
|
+
session.clearGraceTimer();
|
|
1516
|
+
if (session.state === "gone") return;
|
|
1517
|
+
session.state = "gone";
|
|
1518
|
+
const room = session.room;
|
|
1519
|
+
if (!room) return;
|
|
1520
|
+
if (room.clients.get(session.clientId) === session) room.clients.delete(session.clientId);
|
|
1521
|
+
this.clientIds.delete(session.clientId);
|
|
1522
|
+
if (room.relay) {
|
|
1523
|
+
const delta = room.relayRoom?.remove(session.clientId);
|
|
1524
|
+
if (delta && room.relayRoom) {
|
|
1525
|
+
room.lastTick = room.relayRoom.tick;
|
|
1526
|
+
this.broadcast(room, encodeFrame2(FrameType2.DELTA, delta));
|
|
1527
|
+
}
|
|
1528
|
+
} else if (room.state === "running" && room.worker) {
|
|
1529
|
+
room.worker.post({ t: "leave", clientId: session.clientId, reason });
|
|
1530
|
+
} else if (room.state !== "closed") {
|
|
1531
|
+
room.pendingLeaves.add(session.clientId);
|
|
1532
|
+
}
|
|
1533
|
+
this.dropRoomIfIdle(room);
|
|
1534
|
+
}
|
|
1535
|
+
// -------------------------------------------------------------------------
|
|
1536
|
+
// HELLO
|
|
1537
|
+
// -------------------------------------------------------------------------
|
|
1538
|
+
async handleHello(session, payload) {
|
|
1539
|
+
session.clearHelloTimer();
|
|
1540
|
+
let hello;
|
|
1541
|
+
try {
|
|
1542
|
+
hello = decodeHello(payload);
|
|
1543
|
+
} catch {
|
|
1544
|
+
session.fail("E_BAD_FRAME", "malformed HELLO");
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
if (hello.protocolVersion !== PROTOCOL_VERSION) {
|
|
1548
|
+
session.fail(
|
|
1549
|
+
"E_PROTOCOL_VERSION",
|
|
1550
|
+
formatError("E_PROTOCOL_VERSION", { version: hello.protocolVersion })
|
|
1551
|
+
);
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
if (hello.credential.kind !== "key" || hello.credential.key !== this.config.projectId) {
|
|
1555
|
+
session.fail("E_AUTH", formatError("E_AUTH"));
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
if (!this.helloBuckets.take(hello.credential.key)) {
|
|
1559
|
+
session.fail("E_RATE_LIMITED", formatError("E_RATE_LIMITED"));
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
if (this.bundle) {
|
|
1563
|
+
const known = [...this.deployments.values()].some(
|
|
1564
|
+
(d) => d.bundle !== void 0 && bytesEqual(hello.schemaHash8, d.bundle.hash8)
|
|
1565
|
+
);
|
|
1566
|
+
if (!known) {
|
|
1567
|
+
session.fail("E_SCHEMA_MISMATCH", formatError("E_SCHEMA_MISMATCH"));
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
} else if (!isRelayHash8(hello.schemaHash8)) {
|
|
1571
|
+
session.fail("E_SCHEMA_MISMATCH", "this project has no room code deployed (relay tenant)");
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
session.state = "joining";
|
|
1575
|
+
session.role = hello.role ?? "";
|
|
1576
|
+
session.name = hello.name ?? "";
|
|
1577
|
+
let resume;
|
|
1578
|
+
if (hello.resumeToken !== void 0 && hello.resumeToken !== "") {
|
|
1579
|
+
resume = verifyResume(this.resumeSecret, hello.resumeToken);
|
|
1580
|
+
if (!resume) {
|
|
1581
|
+
session.fail("E_RESUME_EXPIRED", formatError("E_RESUME_EXPIRED"));
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
let roomId;
|
|
1586
|
+
if (hello.roomId !== "") {
|
|
1587
|
+
const normalized = normalizeRoomId(hello.roomId);
|
|
1588
|
+
if (!normalized) {
|
|
1589
|
+
session.fail("E_ROOM_NOT_FOUND", formatError("E_ROOM_NOT_FOUND", { roomId: hello.roomId }));
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
if (resume && resume.roomId !== normalized) {
|
|
1593
|
+
session.fail("E_RESUME_EXPIRED", "resume token is for another room");
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
roomId = normalized;
|
|
1597
|
+
} else if (resume) {
|
|
1598
|
+
roomId = resume.roomId;
|
|
1599
|
+
} else {
|
|
1600
|
+
roomId = newRoomCode((code) => this.registry.has(code));
|
|
1601
|
+
}
|
|
1602
|
+
const room = await this.ensureRoom(roomId);
|
|
1603
|
+
if (!room) {
|
|
1604
|
+
session.fail("E_INTERNAL", formatError("E_INTERNAL"));
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
if (room.state === "closed") {
|
|
1608
|
+
session.fail("E_ROOM_CLOSED", formatError("E_ROOM_CLOSED", { roomId }));
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
if (!session.open || session.state !== "joining") return;
|
|
1612
|
+
session.room = room;
|
|
1613
|
+
session.roomId = roomId;
|
|
1614
|
+
let reconnecting = false;
|
|
1615
|
+
if (resume) {
|
|
1616
|
+
reconnecting = true;
|
|
1617
|
+
const existing = room.clients.get(resume.clientId);
|
|
1618
|
+
if (existing && existing !== session) {
|
|
1619
|
+
existing.clearGraceTimer();
|
|
1620
|
+
existing.state = "gone";
|
|
1621
|
+
room.clients.delete(resume.clientId);
|
|
1622
|
+
if (existing.open) {
|
|
1623
|
+
existing.fatal = true;
|
|
1624
|
+
existing.closeSocket(1008, "resumed on another socket");
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
session.clientId = resume.clientId;
|
|
1628
|
+
if (session.role === "") session.role = resume.role;
|
|
1629
|
+
} else {
|
|
1630
|
+
session.clientId = newClientId((id) => this.clientIds.has(id));
|
|
1631
|
+
}
|
|
1632
|
+
this.clientIds.add(session.clientId);
|
|
1633
|
+
room.clients.set(session.clientId, session);
|
|
1634
|
+
if (room.relay) this.joinRelay(room, session, reconnecting);
|
|
1635
|
+
else await this.joinBundle(room, session, reconnecting);
|
|
1636
|
+
}
|
|
1637
|
+
// -------------------------------------------------------------------------
|
|
1638
|
+
// Rooms
|
|
1639
|
+
// -------------------------------------------------------------------------
|
|
1640
|
+
/** Existing room (woken if asleep), or a new one — restored from the store when a snapshot exists. */
|
|
1641
|
+
async ensureRoom(roomId) {
|
|
1642
|
+
const inFlight = this.creating.get(roomId);
|
|
1643
|
+
if (inFlight) return inFlight;
|
|
1644
|
+
const existing = this.registry.get(roomId);
|
|
1645
|
+
if (existing) {
|
|
1646
|
+
if (existing.state === "hibernated") await wakeRoom(this, existing);
|
|
1647
|
+
else await existing.transition.catch(() => {
|
|
1648
|
+
});
|
|
1649
|
+
return existing;
|
|
1650
|
+
}
|
|
1651
|
+
const create = (async () => {
|
|
1652
|
+
const config = this.bundle ? this.bundle.config : relayConfig(this.limits);
|
|
1653
|
+
const room = new RoomRecord(roomId, this.bundle === void 0, config);
|
|
1654
|
+
room.version = this.deploymentVersion;
|
|
1655
|
+
this.registry.set(room);
|
|
1656
|
+
const found = await this.findSnapshot(roomId);
|
|
1657
|
+
const snapshot = found?.bytes;
|
|
1658
|
+
if (room.relay) {
|
|
1659
|
+
room.relayRoom = snapshot ? RelayRoom.restore(snapshot) : RelayRoom.create();
|
|
1660
|
+
room.lastTick = room.relayRoom.tick;
|
|
1661
|
+
room.state = "running";
|
|
1662
|
+
this.armRelayIdle(room);
|
|
1663
|
+
return room;
|
|
1664
|
+
}
|
|
1665
|
+
let migrate;
|
|
1666
|
+
if (found && found.version !== room.version) {
|
|
1667
|
+
try {
|
|
1668
|
+
migrate = this.migrationChain(found.version, room.version);
|
|
1669
|
+
} catch (err) {
|
|
1670
|
+
this.roomLog(room, "error", "cannot migrate this room", err);
|
|
1671
|
+
this.registry.delete(roomId);
|
|
1672
|
+
return void 0;
|
|
1673
|
+
}
|
|
1674
|
+
if (migrate) {
|
|
1675
|
+
this.roomLog(
|
|
1676
|
+
room,
|
|
1677
|
+
"info",
|
|
1678
|
+
`waking a v${found.version} snapshot under v${room.version} (migration chain)`
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
const started = await this.startWorker(room, snapshot, migrate);
|
|
1683
|
+
if (!started) {
|
|
1684
|
+
this.registry.delete(roomId);
|
|
1685
|
+
return void 0;
|
|
1686
|
+
}
|
|
1687
|
+
room.state = "running";
|
|
1688
|
+
this.armSnapshotTimer(room);
|
|
1689
|
+
return room;
|
|
1690
|
+
})();
|
|
1691
|
+
this.creating.set(roomId, create);
|
|
1692
|
+
try {
|
|
1693
|
+
return await create;
|
|
1694
|
+
} finally {
|
|
1695
|
+
this.creating.delete(roomId);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
/** Spawns the worker and waits for `ready`. Leaves the room's state to the caller. */
|
|
1699
|
+
async startWorker(room, snapshot, migrate) {
|
|
1700
|
+
const bundle = this.deployments.get(room.version)?.bundle ?? this.bundle;
|
|
1701
|
+
if (!bundle) return false;
|
|
1702
|
+
const worker = new WorkerHost(this.workerEntry, this.limits, {
|
|
1703
|
+
onMessage: (msg) => this.onWorkerMessage(room, msg),
|
|
1704
|
+
onDead: (reason) => this.onWorkerDead(room, reason)
|
|
1705
|
+
});
|
|
1706
|
+
room.worker = worker;
|
|
1707
|
+
const outcome = await new Promise((resolve2) => {
|
|
1708
|
+
const timer = setTimeout(() => {
|
|
1709
|
+
this.readyWaiters.delete(room);
|
|
1710
|
+
resolve2(void 0);
|
|
1711
|
+
}, this.limits.workerReadyTimeoutMs);
|
|
1712
|
+
timer.unref?.();
|
|
1713
|
+
this.readyWaiters.set(room, (msg) => {
|
|
1714
|
+
clearTimeout(timer);
|
|
1715
|
+
this.readyWaiters.delete(room);
|
|
1716
|
+
resolve2(msg);
|
|
1717
|
+
});
|
|
1718
|
+
worker.post({
|
|
1719
|
+
t: "init",
|
|
1720
|
+
roomId: room.id,
|
|
1721
|
+
bundleUrl: bundle.bundleUrl,
|
|
1722
|
+
...this.config.publicUrl !== void 0 ? { publicUrl: this.config.publicUrl } : {},
|
|
1723
|
+
...snapshot ? { snapshot } : {},
|
|
1724
|
+
...migrate ? { migrate } : {}
|
|
1725
|
+
});
|
|
1726
|
+
});
|
|
1727
|
+
if (!outcome || outcome.t !== "ready") {
|
|
1728
|
+
this.roomLog(
|
|
1729
|
+
room,
|
|
1730
|
+
"error",
|
|
1731
|
+
`worker init failed: ${outcome?.t === "initFailed" ? outcome.reason : "timed out"}`
|
|
1732
|
+
);
|
|
1733
|
+
await worker.terminate();
|
|
1734
|
+
if (room.worker === worker) room.worker = void 0;
|
|
1735
|
+
return false;
|
|
1736
|
+
}
|
|
1737
|
+
room.config = outcome.config;
|
|
1738
|
+
if (outcome.migrated) {
|
|
1739
|
+
const m = outcome.migrated;
|
|
1740
|
+
this.roomLog(
|
|
1741
|
+
room,
|
|
1742
|
+
"info",
|
|
1743
|
+
`migrated v${m.fromVersion} \u2192 v${m.toVersion} in ${m.ms.toFixed(1)} ms` + (m.applied.length > 0 ? ` (ran ${m.applied.map((v) => `v${v}`).join(", ")})` : "")
|
|
1744
|
+
);
|
|
1745
|
+
const bytes = await worker.serialize();
|
|
1746
|
+
if (bytes) {
|
|
1747
|
+
await this.store.put(this.storeKey(room), bytes).catch(() => {
|
|
1748
|
+
});
|
|
1749
|
+
await this.store.delete(this.storeKey(room.id, m.fromVersion)).catch(() => {
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
return true;
|
|
1754
|
+
}
|
|
1755
|
+
armSnapshotTimer(room) {
|
|
1756
|
+
this.clearRoomTimer(room);
|
|
1757
|
+
const every = this.limits.snapshotEveryMs;
|
|
1758
|
+
if (every <= 0 || room.relay || room.config.mode !== "event") return;
|
|
1759
|
+
room.timer = setInterval(() => {
|
|
1760
|
+
void (async () => {
|
|
1761
|
+
if (room.state !== "running" || !room.worker) return;
|
|
1762
|
+
const bytes = await room.worker.serialize();
|
|
1763
|
+
if (bytes) await this.store.put(this.storeKey(room), bytes).catch(() => {
|
|
1764
|
+
});
|
|
1765
|
+
})();
|
|
1766
|
+
}, every);
|
|
1767
|
+
room.timer.unref?.();
|
|
1768
|
+
}
|
|
1769
|
+
/** Relay rooms hibernate after `idleMs` with no frames (plan §3.5). */
|
|
1770
|
+
armRelayIdle(room) {
|
|
1771
|
+
if (!room.relay) return;
|
|
1772
|
+
this.clearRoomTimer(room);
|
|
1773
|
+
if (room.config.idleMs <= 0) return;
|
|
1774
|
+
room.timer = setTimeout(() => {
|
|
1775
|
+
room.timer = void 0;
|
|
1776
|
+
if (room.state === "running") void hibernateRelay(this, room);
|
|
1777
|
+
}, room.config.idleMs);
|
|
1778
|
+
room.timer.unref?.();
|
|
1779
|
+
}
|
|
1780
|
+
clearRoomTimer(room) {
|
|
1781
|
+
if (room.timer !== void 0) {
|
|
1782
|
+
clearTimeout(room.timer);
|
|
1783
|
+
clearInterval(room.timer);
|
|
1784
|
+
room.timer = void 0;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
/** A hibernated room with no sessions left has nothing in memory worth keeping (plan §3.4). */
|
|
1788
|
+
dropRoomIfIdle(room) {
|
|
1789
|
+
if (room.clients.size > 0) return;
|
|
1790
|
+
if (room.state === "hibernated" || room.state === "closed") this.registry.delete(room.id);
|
|
1791
|
+
}
|
|
1792
|
+
closeRoom(room, code, message) {
|
|
1793
|
+
if (room.state === "closed") return;
|
|
1794
|
+
room.state = "closed";
|
|
1795
|
+
this.clearRoomTimer(room);
|
|
1796
|
+
for (const session of [...room.clients.values()]) {
|
|
1797
|
+
session.fatal = true;
|
|
1798
|
+
session.sendError(code, message, true);
|
|
1799
|
+
session.closeSocket(1008, code);
|
|
1800
|
+
session.state = "gone";
|
|
1801
|
+
this.clientIds.delete(session.clientId);
|
|
1802
|
+
}
|
|
1803
|
+
room.clients.clear();
|
|
1804
|
+
void room.worker?.terminate();
|
|
1805
|
+
room.worker = void 0;
|
|
1806
|
+
room.relayRoom = void 0;
|
|
1807
|
+
this.registry.delete(room.id);
|
|
1808
|
+
void this.store.delete(this.storeKey(room)).catch(() => {
|
|
1809
|
+
});
|
|
1810
|
+
this.roomLog(room, "info", `room closed: ${message}`);
|
|
1811
|
+
}
|
|
1812
|
+
// -------------------------------------------------------------------------
|
|
1813
|
+
// Joining
|
|
1814
|
+
// -------------------------------------------------------------------------
|
|
1815
|
+
async joinBundle(room, session, reconnecting) {
|
|
1816
|
+
const outcome = await this.requestJoin(room, session, reconnecting);
|
|
1817
|
+
if (!outcome) {
|
|
1818
|
+
session.fail("E_INTERNAL", "the room did not answer the join");
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
if (outcome.t === "joinRejected") {
|
|
1822
|
+
room.clients.delete(session.clientId);
|
|
1823
|
+
this.clientIds.delete(session.clientId);
|
|
1824
|
+
session.fail(outcome.code, outcome.reason ?? formatError(outcome.code, { roomId: room.id }));
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
if (!session.open) return;
|
|
1828
|
+
session.role = outcome.role;
|
|
1829
|
+
session.state = "joined";
|
|
1830
|
+
if (outcome.tick > room.lastTick) room.lastTick = outcome.tick;
|
|
1831
|
+
room.metrics.connections++;
|
|
1832
|
+
room.metrics.totalConnections++;
|
|
1833
|
+
this.sendWelcome(session, outcome.tick, outcome.snapshot);
|
|
1834
|
+
}
|
|
1835
|
+
requestJoin(room, session, reconnecting) {
|
|
1836
|
+
const worker = room.worker;
|
|
1837
|
+
if (!worker) return Promise.resolve(void 0);
|
|
1838
|
+
return new Promise((resolve2) => {
|
|
1839
|
+
const timer = setTimeout(() => {
|
|
1840
|
+
this.joinWaiters.delete(session.clientId);
|
|
1841
|
+
resolve2(void 0);
|
|
1842
|
+
}, JOIN_TIMEOUT_MS);
|
|
1843
|
+
timer.unref?.();
|
|
1844
|
+
this.joinWaiters.set(session.clientId, (msg) => {
|
|
1845
|
+
clearTimeout(timer);
|
|
1846
|
+
this.joinWaiters.delete(session.clientId);
|
|
1847
|
+
resolve2(msg);
|
|
1848
|
+
});
|
|
1849
|
+
worker.post({
|
|
1850
|
+
t: "join",
|
|
1851
|
+
clientId: session.clientId,
|
|
1852
|
+
...session.role !== "" ? { role: session.role } : {},
|
|
1853
|
+
...session.name !== "" ? { name: session.name } : {},
|
|
1854
|
+
...reconnecting ? { reconnecting: true } : {}
|
|
1855
|
+
});
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
joinRelay(room, session, _reconnecting) {
|
|
1859
|
+
const relay = room.relayRoom;
|
|
1860
|
+
if (!relay) {
|
|
1861
|
+
session.fail("E_INTERNAL", formatError("E_INTERNAL"));
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
if (!relay.has(session.clientId) && relay.size >= room.config.maxClients) {
|
|
1865
|
+
room.clients.delete(session.clientId);
|
|
1866
|
+
this.clientIds.delete(session.clientId);
|
|
1867
|
+
session.fail("E_ROOM_FULL", formatError("E_ROOM_FULL", { roomId: room.id }));
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
const delta = relay.add(session.clientId, session.role, session.name);
|
|
1871
|
+
room.lastTick = relay.tick;
|
|
1872
|
+
session.state = "joined";
|
|
1873
|
+
room.metrics.connections++;
|
|
1874
|
+
room.metrics.totalConnections++;
|
|
1875
|
+
this.sendWelcome(session, relay.tick, relay.snapshot());
|
|
1876
|
+
this.broadcast(room, encodeFrame2(FrameType2.DELTA, delta), session.clientId);
|
|
1877
|
+
this.armRelayIdle(room);
|
|
1878
|
+
}
|
|
1879
|
+
/** `WELCOME` with a fresh resume token — also used as the resync after a wake/restart. */
|
|
1880
|
+
sendWelcome(session, tick, snapshot) {
|
|
1881
|
+
const room = session.room;
|
|
1882
|
+
if (!room) return;
|
|
1883
|
+
const resumeToken = signResume(this.resumeSecret, {
|
|
1884
|
+
clientId: session.clientId,
|
|
1885
|
+
roomId: room.id,
|
|
1886
|
+
role: session.role,
|
|
1887
|
+
expMs: Date.now() + room.config.reconnectGraceMs
|
|
1888
|
+
});
|
|
1889
|
+
const bytes = encodeFrame2(
|
|
1890
|
+
FrameType2.WELCOME,
|
|
1891
|
+
encodeWelcome({
|
|
1892
|
+
clientId: session.clientId,
|
|
1893
|
+
role: session.role,
|
|
1894
|
+
tick,
|
|
1895
|
+
snapshot,
|
|
1896
|
+
resumeToken,
|
|
1897
|
+
roomId: room.id,
|
|
1898
|
+
// 0 for a relay room (tickRate 0): "unknown" — the client falls back to its 50 ms floor.
|
|
1899
|
+
tickIntervalMs: room.config.tickRate > 0 ? Math.round(1e3 / room.config.tickRate) : 0
|
|
1900
|
+
})
|
|
1901
|
+
);
|
|
1902
|
+
const sent = session.send(bytes);
|
|
1903
|
+
if (sent > 0) {
|
|
1904
|
+
room.metrics.egressBytes += sent;
|
|
1905
|
+
room.metrics.framesOut++;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
/** Re-joins every still-connected socket after a wake or a crash restart, with a resync WELCOME. */
|
|
1909
|
+
async rejoinAll(room) {
|
|
1910
|
+
for (const session of room.connected()) {
|
|
1911
|
+
const outcome = await this.requestJoin(room, session, true);
|
|
1912
|
+
if (!outcome || outcome.t !== "joined") {
|
|
1913
|
+
session.fail("E_INTERNAL", "could not rejoin the room");
|
|
1914
|
+
continue;
|
|
1915
|
+
}
|
|
1916
|
+
session.role = outcome.role;
|
|
1917
|
+
if (outcome.tick > room.lastTick) room.lastTick = outcome.tick;
|
|
1918
|
+
this.sendWelcome(session, outcome.tick, outcome.snapshot);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
/** After a relay wake: presence comes back from the snapshot; reconcile it with the live sockets. */
|
|
1922
|
+
reconcileRelayPresence(room) {
|
|
1923
|
+
const relay = room.relayRoom;
|
|
1924
|
+
if (!relay) return;
|
|
1925
|
+
for (const id of relay.ids()) {
|
|
1926
|
+
if (!room.clients.has(id)) relay.remove(id);
|
|
1927
|
+
}
|
|
1928
|
+
for (const [id, session] of room.clients) {
|
|
1929
|
+
if (session.state === "gone") continue;
|
|
1930
|
+
if (relay.has(id)) relay.setConnected(id, session.open);
|
|
1931
|
+
else relay.add(id, session.role, session.name);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
// -------------------------------------------------------------------------
|
|
1935
|
+
// Frames
|
|
1936
|
+
// -------------------------------------------------------------------------
|
|
1937
|
+
routeFrame(session, bytes) {
|
|
1938
|
+
const room = session.room;
|
|
1939
|
+
if (!room || session.state !== "joined") return;
|
|
1940
|
+
room.metrics.ingressBytes += bytes.length;
|
|
1941
|
+
room.metrics.framesIn++;
|
|
1942
|
+
if (!session.frames.take()) {
|
|
1943
|
+
session.rateStrikes++;
|
|
1944
|
+
if (session.rateStrikes >= MAX_STRIKES)
|
|
1945
|
+
session.fail("E_RATE_LIMITED", formatError("E_RATE_LIMITED"));
|
|
1946
|
+
else session.sendError("E_RATE_LIMITED", formatError("E_RATE_LIMITED"), false);
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
let type;
|
|
1950
|
+
let payload;
|
|
1951
|
+
try {
|
|
1952
|
+
const frame = decodeFrame(bytes);
|
|
1953
|
+
type = frame.type;
|
|
1954
|
+
payload = frame.payload;
|
|
1955
|
+
} catch {
|
|
1956
|
+
this.badFrame(session, "unknown frame type");
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
if (type === FrameType2.PING) {
|
|
1960
|
+
let t = 0;
|
|
1961
|
+
try {
|
|
1962
|
+
t = decodePing(payload).t;
|
|
1963
|
+
} catch {
|
|
1964
|
+
}
|
|
1965
|
+
const sent = session.sendFrame(FrameType2.PONG, encodePong({ t, serverTick: room.lastTick }));
|
|
1966
|
+
if (sent > 0) {
|
|
1967
|
+
room.metrics.egressBytes += sent;
|
|
1968
|
+
room.metrics.framesOut++;
|
|
1969
|
+
}
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
if (type === FrameType2.LEAVE) {
|
|
1973
|
+
this.handleLeaveFrame(session);
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
if (room.relay) {
|
|
1977
|
+
this.relayFrame(room, session, type, payload);
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
if (room.state === "running" && room.worker) {
|
|
1981
|
+
room.worker.post({ t: "frame", clientId: session.clientId, bytes });
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
if (room.state === "closed") return;
|
|
1985
|
+
room.enqueue({ clientId: session.clientId, bytes });
|
|
1986
|
+
if (room.state === "hibernated") void wakeRoom(this, room);
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* `LEAVE` (week 7, D-LEAVE): a deliberate departure, sent right before the client closes its
|
|
1990
|
+
* socket. Expires the session immediately with reason `'left'` — no grace window, no waiting for
|
|
1991
|
+
* the close event — mirroring the fatal/kicked path in `onSocketClose`. When the socket does
|
|
1992
|
+
* close moments later, `onSocketClose` finds the session already `'gone'` and no-ops.
|
|
1993
|
+
*/
|
|
1994
|
+
handleLeaveFrame(session) {
|
|
1995
|
+
const room = session.room;
|
|
1996
|
+
if (!room || session.state !== "joined") return;
|
|
1997
|
+
room.metrics.connections = Math.max(0, room.metrics.connections - 1);
|
|
1998
|
+
this.expireSession(session, "left");
|
|
1999
|
+
}
|
|
2000
|
+
/** Replays one queued frame after a wake (bundle → worker, relay → forward). */
|
|
2001
|
+
deliverQueued(room, frame) {
|
|
2002
|
+
if (!room.relay) {
|
|
2003
|
+
room.worker?.post({ t: "frame", clientId: frame.clientId, bytes: frame.bytes });
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
const session = room.clients.get(frame.clientId);
|
|
2007
|
+
if (!session || session.state !== "joined") return;
|
|
2008
|
+
try {
|
|
2009
|
+
const decoded = decodeFrame(frame.bytes);
|
|
2010
|
+
this.relayFrame(room, session, decoded.type, decoded.payload);
|
|
2011
|
+
} catch {
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
relayFrame(room, session, type, payload) {
|
|
2015
|
+
const relay = room.relayRoom;
|
|
2016
|
+
if (!relay) return;
|
|
2017
|
+
if (type !== FrameType2.MSG) {
|
|
2018
|
+
this.badFrame(session, "relay rooms accept MSG only (no room code is deployed)");
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
let target;
|
|
2022
|
+
let body;
|
|
2023
|
+
try {
|
|
2024
|
+
const msg = decodeMsg(payload);
|
|
2025
|
+
target = msg.target;
|
|
2026
|
+
body = msg.payload;
|
|
2027
|
+
} catch {
|
|
2028
|
+
this.badFrame(session, "malformed MSG");
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
if (target.kind === "server") {
|
|
2032
|
+
this.badFrame(session, "relay rooms have no server to address");
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
const out = encodeFrame2(
|
|
2036
|
+
FrameType2.MSG,
|
|
2037
|
+
encodeMsg({ target: { kind: "client", clientId: session.clientId }, payload: body })
|
|
2038
|
+
);
|
|
2039
|
+
for (const peer of room.clients.values()) {
|
|
2040
|
+
if (peer === session || peer.state !== "joined") continue;
|
|
2041
|
+
if (target.kind === "client" && peer.clientId !== target.clientId) continue;
|
|
2042
|
+
if (target.kind === "role" && peer.role !== target.role) continue;
|
|
2043
|
+
const sent = peer.send(out);
|
|
2044
|
+
if (sent > 0) {
|
|
2045
|
+
room.metrics.egressBytes += sent;
|
|
2046
|
+
room.metrics.framesOut++;
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
room.lastTick = relay.advance();
|
|
2050
|
+
this.armRelayIdle(room);
|
|
2051
|
+
}
|
|
2052
|
+
badFrame(session, message) {
|
|
2053
|
+
session.badFrameStrikes++;
|
|
2054
|
+
if (session.badFrameStrikes >= MAX_STRIKES) session.fail("E_BAD_FRAME", message);
|
|
2055
|
+
else session.sendError("E_BAD_FRAME", message, false);
|
|
2056
|
+
}
|
|
2057
|
+
broadcast(room, bytes, exceptClientId) {
|
|
2058
|
+
for (const session of room.clients.values()) {
|
|
2059
|
+
if (session.state !== "joined") continue;
|
|
2060
|
+
if (exceptClientId !== void 0 && session.clientId === exceptClientId) continue;
|
|
2061
|
+
const sent = session.send(bytes);
|
|
2062
|
+
if (sent > 0) {
|
|
2063
|
+
room.metrics.egressBytes += sent;
|
|
2064
|
+
room.metrics.framesOut++;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
// -------------------------------------------------------------------------
|
|
2069
|
+
// Worker messages
|
|
2070
|
+
// -------------------------------------------------------------------------
|
|
2071
|
+
onWorkerMessage(room, msg) {
|
|
2072
|
+
switch (msg.t) {
|
|
2073
|
+
case "ready":
|
|
2074
|
+
case "initFailed":
|
|
2075
|
+
this.readyWaiters.get(room)?.(msg);
|
|
2076
|
+
return;
|
|
2077
|
+
case "joined":
|
|
2078
|
+
case "joinRejected":
|
|
2079
|
+
this.joinWaiters.get(msg.clientId)?.(msg);
|
|
2080
|
+
return;
|
|
2081
|
+
case "send": {
|
|
2082
|
+
noteTick(room, msg.bytes);
|
|
2083
|
+
const session = room.clients.get(msg.clientId);
|
|
2084
|
+
if (!session || session.state !== "joined") return;
|
|
2085
|
+
const sent = session.send(msg.bytes);
|
|
2086
|
+
if (sent > 0) {
|
|
2087
|
+
room.metrics.egressBytes += sent;
|
|
2088
|
+
room.metrics.framesOut++;
|
|
2089
|
+
}
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
case "kick": {
|
|
2093
|
+
const session = room.clients.get(msg.clientId);
|
|
2094
|
+
if (!session) return;
|
|
2095
|
+
session.fail(msg.code, msg.reason ?? formatError(msg.code, { reason: "kicked" }));
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
case "close":
|
|
2099
|
+
this.closeRoom(
|
|
2100
|
+
room,
|
|
2101
|
+
"E_ROOM_CLOSED",
|
|
2102
|
+
msg.reason ?? formatError("E_ROOM_CLOSED", { roomId: room.id })
|
|
2103
|
+
);
|
|
2104
|
+
return;
|
|
2105
|
+
case "sleep":
|
|
2106
|
+
void hibernateRoom(this, room);
|
|
2107
|
+
return;
|
|
2108
|
+
case "log":
|
|
2109
|
+
room.log(msg.level, ...msg.args);
|
|
2110
|
+
return;
|
|
2111
|
+
case "crashed":
|
|
2112
|
+
this.roomLog(room, "error", `room crashed: ${msg.reason}`);
|
|
2113
|
+
return;
|
|
2114
|
+
default:
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
/** Worker died: restart from the latest snapshot and resync everyone (plan §3.3). */
|
|
2119
|
+
onWorkerDead(room, reason) {
|
|
2120
|
+
if (room.state === "closed" || this.closing) return;
|
|
2121
|
+
this.roomLog(room, "error", `worker died (${reason}); restarting`);
|
|
2122
|
+
room.worker = void 0;
|
|
2123
|
+
room.metrics.restarts++;
|
|
2124
|
+
room.metrics.droppedFramesOnRestart += room.wakeQueue.length;
|
|
2125
|
+
room.wakeQueue.length = 0;
|
|
2126
|
+
const now = Date.now();
|
|
2127
|
+
while (room.restarts.length > 0 && now - (room.restarts[0] ?? 0) > 6e4)
|
|
2128
|
+
room.restarts.shift();
|
|
2129
|
+
room.restarts.push(now);
|
|
2130
|
+
if (room.restarts.length > this.limits.maxRestartsPerMin) {
|
|
2131
|
+
this.closeRoom(
|
|
2132
|
+
room,
|
|
2133
|
+
"E_INTERNAL",
|
|
2134
|
+
`room restarted ${room.restarts.length} times in a minute; giving up`
|
|
2135
|
+
);
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
room.state = "starting";
|
|
2139
|
+
const work = (async () => {
|
|
2140
|
+
const snapshot = await this.store.get(this.storeKey(room));
|
|
2141
|
+
if (!snapshot)
|
|
2142
|
+
this.roomLog(room, "warn", "restarting with a fresh room (no snapshot in the store)");
|
|
2143
|
+
const started = await this.startWorker(room, snapshot);
|
|
2144
|
+
if (!started) {
|
|
2145
|
+
this.closeRoom(room, "E_INTERNAL", "the room could not be restarted");
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
room.state = "running";
|
|
2149
|
+
this.armSnapshotTimer(room);
|
|
2150
|
+
await this.rejoinAll(room);
|
|
2151
|
+
})();
|
|
2152
|
+
room.transition = work.catch((err) => {
|
|
2153
|
+
this.roomLog(room, "error", "restart failed", err);
|
|
2154
|
+
this.closeRoom(room, "E_INTERNAL", "the room could not be restarted");
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
// -------------------------------------------------------------------------
|
|
2158
|
+
// Tenant idle
|
|
2159
|
+
// -------------------------------------------------------------------------
|
|
2160
|
+
checkTenantIdle(tenantIdleMs) {
|
|
2161
|
+
if (this.closing) return;
|
|
2162
|
+
if (this.sessions.size > 0) {
|
|
2163
|
+
this.idleSince = 0;
|
|
2164
|
+
this.idleFired = false;
|
|
2165
|
+
this.idleFlushed = false;
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
if (this.idleSince === 0) {
|
|
2169
|
+
this.idleSince = Date.now();
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
if (this.idleFired || Date.now() - this.idleSince < tenantIdleMs) return;
|
|
2173
|
+
this.idleFired = true;
|
|
2174
|
+
void (async () => {
|
|
2175
|
+
this.log("info", "tenant idle: flushing every room");
|
|
2176
|
+
try {
|
|
2177
|
+
await flushAll(this);
|
|
2178
|
+
} catch (err) {
|
|
2179
|
+
this.log("error", "tenant-idle flush failed", err);
|
|
2180
|
+
}
|
|
2181
|
+
this.idleFlushed = true;
|
|
2182
|
+
this.config.onTenantIdle?.();
|
|
2183
|
+
})();
|
|
2184
|
+
}
|
|
2185
|
+
// -------------------------------------------------------------------------
|
|
2186
|
+
// Admin API (plan §3.8)
|
|
2187
|
+
// -------------------------------------------------------------------------
|
|
2188
|
+
rooms() {
|
|
2189
|
+
return this.registry.values().map((r) => r.info());
|
|
2190
|
+
}
|
|
2191
|
+
metrics() {
|
|
2192
|
+
const roomsByState = emptyRoomsByState();
|
|
2193
|
+
let egressBytes = 0;
|
|
2194
|
+
let ingressBytes = 0;
|
|
2195
|
+
for (const room of this.registry.values()) {
|
|
2196
|
+
roomsByState[room.state]++;
|
|
2197
|
+
egressBytes += room.metrics.egressBytes;
|
|
2198
|
+
ingressBytes += room.metrics.ingressBytes;
|
|
2199
|
+
}
|
|
2200
|
+
return {
|
|
2201
|
+
sockets: this.sessions.size,
|
|
2202
|
+
roomsByState,
|
|
2203
|
+
egressBytes,
|
|
2204
|
+
ingressBytes,
|
|
2205
|
+
rss: process.memoryUsage().rss,
|
|
2206
|
+
unhandledRejections: this.processMetrics.unhandledRejections,
|
|
2207
|
+
snapshotFlushFailures: this.processMetrics.snapshotFlushFailures
|
|
2208
|
+
};
|
|
2209
|
+
}
|
|
2210
|
+
logs(roomId) {
|
|
2211
|
+
return this.registry.get(roomId)?.logs.list() ?? [];
|
|
2212
|
+
}
|
|
2213
|
+
async inspect(roomId) {
|
|
2214
|
+
const room = this.registry.get(roomId);
|
|
2215
|
+
if (!room) return void 0;
|
|
2216
|
+
if (room.relay) {
|
|
2217
|
+
const relay = room.relayRoom;
|
|
2218
|
+
if (!relay) return void 0;
|
|
2219
|
+
return {
|
|
2220
|
+
tick: relay.tick,
|
|
2221
|
+
state: inspectState(relaySchema2, relay.state),
|
|
2222
|
+
recent: [],
|
|
2223
|
+
rss: 0
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
if (room.state !== "running" || !room.worker) return void 0;
|
|
2227
|
+
const inspected = await room.worker.inspect();
|
|
2228
|
+
if (!inspected) return void 0;
|
|
2229
|
+
return {
|
|
2230
|
+
tick: inspected.tick,
|
|
2231
|
+
state: inspected.stateJson,
|
|
2232
|
+
recent: inspected.recent,
|
|
2233
|
+
rss: inspected.rss
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* Ops/test hook: hard-kill a room's worker. The room takes the normal §3.3 crash path (restore
|
|
2238
|
+
* from the latest snapshot, resync everyone, restart cap). `false` when the room has no worker.
|
|
2239
|
+
*/
|
|
2240
|
+
async killWorker(roomId) {
|
|
2241
|
+
const room = this.registry.get(roomId);
|
|
2242
|
+
if (!room?.worker) return false;
|
|
2243
|
+
await room.worker.kill();
|
|
2244
|
+
return true;
|
|
2245
|
+
}
|
|
2246
|
+
};
|
|
2247
|
+
function header(v) {
|
|
2248
|
+
if (v === void 0) return void 0;
|
|
2249
|
+
return Array.isArray(v) ? v[0] : v;
|
|
2250
|
+
}
|
|
2251
|
+
function noteTick(room, bytes) {
|
|
2252
|
+
if (bytes.length < 5) return;
|
|
2253
|
+
const type = bytes[0];
|
|
2254
|
+
if (type !== FrameType2.DELTA && type !== FrameType2.CORRECT) return;
|
|
2255
|
+
const tick = new DataView(bytes.buffer, bytes.byteOffset + 1, 4).getUint32(0, true);
|
|
2256
|
+
if (tick > room.lastTick) room.lastTick = tick;
|
|
2257
|
+
}
|
|
2258
|
+
async function loadBundle(bundlePath, version) {
|
|
2259
|
+
const bundleUrl = bundlePath.startsWith("file:") ? bundlePath : pathToFileURL2(bundlePath).href;
|
|
2260
|
+
let mod;
|
|
2261
|
+
try {
|
|
2262
|
+
mod = await import(bundleUrl);
|
|
2263
|
+
} catch (err) {
|
|
2264
|
+
throw new Error(`irtio: the room bundle at ${bundlePath} failed to load: ${String(err)}`);
|
|
2265
|
+
}
|
|
2266
|
+
if (!isRoomDefinition(mod.default)) {
|
|
2267
|
+
throw new Error(
|
|
2268
|
+
`irtio: ${bundlePath} does not export a room \u2014 end your room file with: export default defineRoom(schema, {...})`
|
|
2269
|
+
);
|
|
2270
|
+
}
|
|
2271
|
+
const definition = mod.default;
|
|
2272
|
+
const config = definition.config;
|
|
2273
|
+
return {
|
|
2274
|
+
version,
|
|
2275
|
+
definition,
|
|
2276
|
+
schemaHash: definition.schema.hash,
|
|
2277
|
+
hash8: definition.schema.hash8,
|
|
2278
|
+
extHash8: withBuiltins(definition.schema).hash8,
|
|
2279
|
+
config: {
|
|
2280
|
+
mode: config.mode,
|
|
2281
|
+
tickRate: config.tickRate,
|
|
2282
|
+
idleMs: config.idleMs,
|
|
2283
|
+
reconnectGraceMs: config.reconnectGraceMs,
|
|
2284
|
+
maxClients: config.maxClients
|
|
2285
|
+
},
|
|
2286
|
+
bundleUrl,
|
|
2287
|
+
schemaJson: definition.schema.canonical
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
function resolveWorkerEntry() {
|
|
2291
|
+
const resolve2 = import.meta.resolve;
|
|
2292
|
+
if (typeof resolve2 !== "function") {
|
|
2293
|
+
throw new Error("irtio: no workerEntry configured and import.meta.resolve is unavailable");
|
|
2294
|
+
}
|
|
2295
|
+
return resolve2("@irtio/runtime/worker");
|
|
2296
|
+
}
|
|
2297
|
+
function bundleRefsOf(config) {
|
|
2298
|
+
if (config.bundles && config.bundles.length > 0) return config.bundles;
|
|
2299
|
+
if (config.bundlePath !== void 0) return [{ version: 0, bundlePath: config.bundlePath }];
|
|
2300
|
+
return [];
|
|
2301
|
+
}
|
|
2302
|
+
function toFileUrl(p) {
|
|
2303
|
+
return p.startsWith("file:") ? p : pathToFileURL2(p).href;
|
|
2304
|
+
}
|
|
2305
|
+
function createSupervisor(config) {
|
|
2306
|
+
return new SupervisorImpl(config);
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// src/dev.ts
|
|
2310
|
+
import * as esbuild from "esbuild";
|
|
2311
|
+
import pc from "picocolors";
|
|
2312
|
+
|
|
2313
|
+
// src/dev-page.ts
|
|
2314
|
+
var DEV_PAGE = `<!doctype html>
|
|
2315
|
+
<html lang="en">
|
|
2316
|
+
<head>
|
|
2317
|
+
<meta charset="utf-8" />
|
|
2318
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2319
|
+
<title>irtio dev</title>
|
|
2320
|
+
<style>
|
|
2321
|
+
:root { color-scheme: dark; --bg:#0b0e14; --panel:#131722; --line:#232838; --fg:#c9d1e1;
|
|
2322
|
+
--dim:#6b7590; --accent:#7dd3fc; --good:#86efac; --warn:#fcd34d; --bad:#fca5a5; }
|
|
2323
|
+
* { box-sizing: border-box; }
|
|
2324
|
+
body { margin:0; background:var(--bg); color:var(--fg); font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
|
2325
|
+
header { display:flex; flex-wrap:wrap; gap:16px; align-items:baseline;
|
|
2326
|
+
padding:10px 16px; border-bottom:1px solid var(--line); background:var(--panel); }
|
|
2327
|
+
header b { color:var(--accent); font-weight:600; }
|
|
2328
|
+
.dim { color:var(--dim); }
|
|
2329
|
+
main { display:grid; grid-template-columns:260px 1fr; gap:1px; background:var(--line);
|
|
2330
|
+
min-height:calc(100vh - 84px); }
|
|
2331
|
+
section { background:var(--bg); padding:12px 16px; overflow:auto; }
|
|
2332
|
+
h2 { font-size:11px; letter-spacing:.12em; text-transform:uppercase; color:var(--dim);
|
|
2333
|
+
margin:16px 0 6px; font-weight:600; }
|
|
2334
|
+
h2:first-child { margin-top:0; }
|
|
2335
|
+
table { border-collapse:collapse; width:100%; }
|
|
2336
|
+
th, td { text-align:left; padding:3px 8px 3px 0; border-bottom:1px solid var(--line);
|
|
2337
|
+
white-space:nowrap; }
|
|
2338
|
+
th { color:var(--dim); font-weight:400; }
|
|
2339
|
+
pre { background:var(--panel); border:1px solid var(--line); border-radius:4px; padding:10px;
|
|
2340
|
+
margin:0; max-height:40vh; overflow:auto; }
|
|
2341
|
+
.room { display:block; width:100%; text-align:left; background:none; color:inherit;
|
|
2342
|
+
border:1px solid transparent; border-radius:4px; padding:6px 8px; margin-bottom:4px;
|
|
2343
|
+
font:inherit; cursor:pointer; }
|
|
2344
|
+
.room:hover { background:var(--panel); }
|
|
2345
|
+
.room.sel { background:var(--panel); border-color:var(--accent); }
|
|
2346
|
+
.room code { color:var(--accent); font-size:14px; }
|
|
2347
|
+
.running { color:var(--good); } .closed, .error { color:var(--bad); }
|
|
2348
|
+
.hibernated, .waking, .hibernating, .starting { color:var(--warn); }
|
|
2349
|
+
footer { padding:8px 16px; border-top:1px solid var(--line); background:var(--panel);
|
|
2350
|
+
display:flex; gap:20px; flex-wrap:wrap; }
|
|
2351
|
+
.empty { color:var(--dim); font-style:italic; }
|
|
2352
|
+
</style>
|
|
2353
|
+
</head>
|
|
2354
|
+
<body>
|
|
2355
|
+
<header>
|
|
2356
|
+
<b>irtio dev</b>
|
|
2357
|
+
<span>project <b id="project">\u2026</b></span>
|
|
2358
|
+
<span class="dim">bundle</span><span id="hash">\u2026</span>
|
|
2359
|
+
<span class="dim">schema</span><span id="schema">\u2026</span>
|
|
2360
|
+
<span class="dim">mode</span><span id="mode">\u2026</span>
|
|
2361
|
+
<span id="status" class="dim" style="margin-left:auto"></span>
|
|
2362
|
+
</header>
|
|
2363
|
+
<main>
|
|
2364
|
+
<section id="rooms"></section>
|
|
2365
|
+
<section id="detail"><p class="empty">no room selected</p></section>
|
|
2366
|
+
</main>
|
|
2367
|
+
<footer id="tenant"></footer>
|
|
2368
|
+
<script>
|
|
2369
|
+
var selected = null;
|
|
2370
|
+
var esc = function (s) {
|
|
2371
|
+
return String(s).replace(/[&<>]/g, function (c) {
|
|
2372
|
+
return c === '&' ? '&' : c === '<' ? '<' : '>';
|
|
2373
|
+
});
|
|
2374
|
+
};
|
|
2375
|
+
var kb = function (n) { return (n / 1024).toFixed(1) + ' KiB'; };
|
|
2376
|
+
var rows = function (head, body) {
|
|
2377
|
+
return '<table><tr>' + head.map(function (h) { return '<th>' + h + '</th>'; }).join('') +
|
|
2378
|
+
'</tr>' + body + '</table>';
|
|
2379
|
+
};
|
|
2380
|
+
|
|
2381
|
+
function renderRooms(state) {
|
|
2382
|
+
var el = document.getElementById('rooms');
|
|
2383
|
+
if (!state.rooms.length) {
|
|
2384
|
+
el.innerHTML = '<h2>rooms</h2><p class="empty">no rooms yet \u2014 connect a client</p>';
|
|
2385
|
+
return;
|
|
2386
|
+
}
|
|
2387
|
+
if (!state.rooms.some(function (r) { return r.id === selected; })) selected = state.rooms[0].id;
|
|
2388
|
+
el.innerHTML = '<h2>rooms (' + state.rooms.length + ')</h2>' + state.rooms.map(function (r) {
|
|
2389
|
+
return '<button class="room' + (r.id === selected ? ' sel' : '') + '" data-id="' + esc(r.id) +
|
|
2390
|
+
'"><code>' + esc(r.id) + '</code> <span class="' + esc(r.state) + '">' + esc(r.state) +
|
|
2391
|
+
'</span><br><span class="dim">tick ' + r.tick + ' \xB7 ' + r.metrics.connections +
|
|
2392
|
+
' client' + (r.metrics.connections === 1 ? '' : 's') + '</span></button>';
|
|
2393
|
+
}).join('');
|
|
2394
|
+
Array.prototype.forEach.call(el.querySelectorAll('.room'), function (b) {
|
|
2395
|
+
b.onclick = function () { selected = b.getAttribute('data-id'); tick(); };
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
function renderDetail(state) {
|
|
2400
|
+
var el = document.getElementById('detail');
|
|
2401
|
+
var room = state.rooms.filter(function (r) { return r.id === selected; })[0];
|
|
2402
|
+
if (!room) { el.innerHTML = '<p class="empty">no room selected</p>'; return; }
|
|
2403
|
+
var html = '<h2>presence \u2014 ' + esc(room.id) + '</h2>';
|
|
2404
|
+
html += room.clients.length
|
|
2405
|
+
? rows(['client', 'role', 'name', 'state'], room.clients.map(function (c) {
|
|
2406
|
+
return '<tr><td>' + esc(c.clientId) + '</td><td>' + esc(c.role) + '</td><td>' +
|
|
2407
|
+
esc(c.name) + '</td><td>' + esc(c.state) + '</td></tr>';
|
|
2408
|
+
}).join(''))
|
|
2409
|
+
: '<p class="empty">nobody here</p>';
|
|
2410
|
+
html += '<h2>state</h2><pre>' +
|
|
2411
|
+
(room.inspect ? esc(JSON.stringify(room.inspect.state, null, 2)) :
|
|
2412
|
+
'<span class="empty">no live state (room not running)</span>') + '</pre>';
|
|
2413
|
+
html += '<h2>recent events</h2>';
|
|
2414
|
+
var recent = (room.inspect && room.inspect.recent) || [];
|
|
2415
|
+
html += recent.length
|
|
2416
|
+
? rows(['tick', 'kind', 'client', 'detail'], recent.slice().reverse().map(function (e) {
|
|
2417
|
+
return '<tr><td>' + e.tick + '</td><td>' + esc(e.kind) + '</td><td>' +
|
|
2418
|
+
esc(e.clientId || '') + '</td><td>' +
|
|
2419
|
+
esc(e.detail === undefined ? '' : JSON.stringify(e.detail)) + '</td></tr>';
|
|
2420
|
+
}).join(''))
|
|
2421
|
+
: '<p class="empty">no events yet</p>';
|
|
2422
|
+
html += '<h2>room metrics</h2>' + rows(['metric', 'value'],
|
|
2423
|
+
Object.keys(room.metrics).map(function (k) {
|
|
2424
|
+
return '<tr><td class="dim">' + esc(k) + '</td><td>' + esc(room.metrics[k]) + '</td></tr>';
|
|
2425
|
+
}).join(''));
|
|
2426
|
+
el.innerHTML = html;
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
function renderTenant(state) {
|
|
2430
|
+
var m = state.metrics;
|
|
2431
|
+
var by = Object.keys(m.roomsByState).filter(function (k) { return m.roomsByState[k] > 0; })
|
|
2432
|
+
.map(function (k) { return k + ' ' + m.roomsByState[k]; }).join(', ') || 'none';
|
|
2433
|
+
document.getElementById('tenant').innerHTML =
|
|
2434
|
+
'<span><span class="dim">sockets</span> ' + m.sockets + '</span>' +
|
|
2435
|
+
'<span><span class="dim">rooms</span> ' + esc(by) + '</span>' +
|
|
2436
|
+
'<span><span class="dim">in</span> ' + kb(m.ingressBytes) + '</span>' +
|
|
2437
|
+
'<span><span class="dim">out</span> ' + kb(m.egressBytes) + '</span>' +
|
|
2438
|
+
'<span><span class="dim">rss</span> ' + (m.rss / 1048576).toFixed(0) + ' MiB</span>' +
|
|
2439
|
+
'<span class="dim">' + state.logs.length + ' log lines</span>';
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
function tick() {
|
|
2443
|
+
return fetch('state.json').then(function (r) { return r.json(); }).then(function (state) {
|
|
2444
|
+
document.getElementById('project').textContent = state.project;
|
|
2445
|
+
document.getElementById('hash').textContent = (state.bundle.hash || '').slice(0, 8) || '\u2014';
|
|
2446
|
+
document.getElementById('schema').textContent =
|
|
2447
|
+
(state.bundle.schemaHash || '').slice(0, 8) || '\u2014';
|
|
2448
|
+
document.getElementById('mode').textContent = state.bundle.mode || '\u2014';
|
|
2449
|
+
document.getElementById('status').textContent = new Date().toLocaleTimeString();
|
|
2450
|
+
renderRooms(state);
|
|
2451
|
+
renderDetail(state);
|
|
2452
|
+
renderTenant(state);
|
|
2453
|
+
}).catch(function (err) {
|
|
2454
|
+
document.getElementById('status').textContent = 'disconnected \u2014 ' + err;
|
|
2455
|
+
});
|
|
2456
|
+
}
|
|
2457
|
+
tick();
|
|
2458
|
+
setInterval(tick, 1000);
|
|
2459
|
+
</script>
|
|
2460
|
+
</body>
|
|
2461
|
+
</html>
|
|
2462
|
+
`;
|
|
2463
|
+
|
|
2464
|
+
// src/dev.ts
|
|
2465
|
+
var ROOM_CANDIDATES = ["irtio/room.ts", "irtio/room.js", "room.ts"];
|
|
2466
|
+
var DEFAULT_PORT = 7070;
|
|
2467
|
+
var DEBOUNCE_MS = 150;
|
|
2468
|
+
var WATCHED_EXTENSIONS = [".ts", ".js", ".mts", ".mjs", ".tsx", ".jsx"];
|
|
2469
|
+
var LOG_LIMIT = 100;
|
|
2470
|
+
function resolveEntry(cwd, room) {
|
|
2471
|
+
if (room !== void 0) {
|
|
2472
|
+
const file = path2.resolve(cwd, room);
|
|
2473
|
+
if (!existsSync(file)) throw new Error(`irtio dev: no room file at ${file}`);
|
|
2474
|
+
return file;
|
|
2475
|
+
}
|
|
2476
|
+
for (const candidate of ROOM_CANDIDATES) {
|
|
2477
|
+
const file = path2.resolve(cwd, candidate);
|
|
2478
|
+
if (existsSync(file)) return file;
|
|
2479
|
+
}
|
|
2480
|
+
throw new Error(
|
|
2481
|
+
`irtio dev: no room file found in ${cwd}
|
|
2482
|
+
looked for: ${ROOM_CANDIDATES.join(", ")}
|
|
2483
|
+
create one (export default defineRoom(schema, {...})) or pass --room <file>`
|
|
2484
|
+
);
|
|
2485
|
+
}
|
|
2486
|
+
async function readProjectId(cwd) {
|
|
2487
|
+
const file = path2.join(cwd, "irtio.json");
|
|
2488
|
+
if (!existsSync(file)) return "dev";
|
|
2489
|
+
let parsed;
|
|
2490
|
+
try {
|
|
2491
|
+
parsed = JSON.parse(await readFile2(file, "utf8"));
|
|
2492
|
+
} catch (err) {
|
|
2493
|
+
throw new Error(`irtio dev: ${file} is not valid JSON: ${String(err)}`);
|
|
2494
|
+
}
|
|
2495
|
+
const project = parsed?.project;
|
|
2496
|
+
if (project === void 0) return "dev";
|
|
2497
|
+
if (typeof project !== "string" || project.length === 0) {
|
|
2498
|
+
throw new Error(`irtio dev: ${file} has a "project" that is not a non-empty string`);
|
|
2499
|
+
}
|
|
2500
|
+
return project;
|
|
2501
|
+
}
|
|
2502
|
+
async function resolveWorkerEntry2(outDir) {
|
|
2503
|
+
const resolver = import.meta.resolve;
|
|
2504
|
+
if (typeof resolver === "function") {
|
|
2505
|
+
try {
|
|
2506
|
+
const href = resolver("@irtio/runtime/worker");
|
|
2507
|
+
const file = href.startsWith("file:") ? fileURLToPath(href) : href;
|
|
2508
|
+
if (!file.endsWith(".ts") && existsSync(file)) return void 0;
|
|
2509
|
+
} catch {
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
const packagesDir = path2.resolve(path2.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
2513
|
+
const source = path2.join(packagesDir, "runtime/src/worker/index.ts");
|
|
2514
|
+
if (!existsSync(source)) {
|
|
2515
|
+
throw new Error(
|
|
2516
|
+
"irtio dev: could not resolve @irtio/runtime/worker \u2014 is @irtio/runtime installed and built?"
|
|
2517
|
+
);
|
|
2518
|
+
}
|
|
2519
|
+
const outfile = path2.join(outDir, "worker.mjs");
|
|
2520
|
+
await esbuild.build({
|
|
2521
|
+
entryPoints: [source],
|
|
2522
|
+
bundle: true,
|
|
2523
|
+
format: "esm",
|
|
2524
|
+
platform: "node",
|
|
2525
|
+
outfile,
|
|
2526
|
+
alias: {
|
|
2527
|
+
"@irtio/schema": path2.join(packagesDir, "schema/src/index.ts"),
|
|
2528
|
+
"@irtio/server": path2.join(packagesDir, "server/src/index.ts"),
|
|
2529
|
+
"@irtio/protocol": path2.join(packagesDir, "protocol/src/index.ts")
|
|
2530
|
+
},
|
|
2531
|
+
external: ["node:worker_threads", "node:url"]
|
|
2532
|
+
});
|
|
2533
|
+
return outfile;
|
|
2534
|
+
}
|
|
2535
|
+
async function buildState(supervisor, projectId, bundle) {
|
|
2536
|
+
const infos = supervisor.rooms();
|
|
2537
|
+
const inspects = await Promise.all(
|
|
2538
|
+
infos.map((room) => supervisor.inspect(room.id).catch(() => void 0))
|
|
2539
|
+
);
|
|
2540
|
+
const logs = [];
|
|
2541
|
+
for (const room of infos) {
|
|
2542
|
+
for (const entry of supervisor.logs(room.id)) logs.push({ ...entry, roomId: room.id });
|
|
2543
|
+
}
|
|
2544
|
+
logs.sort((a, b) => a.at - b.at);
|
|
2545
|
+
return {
|
|
2546
|
+
project: projectId,
|
|
2547
|
+
bundle: {
|
|
2548
|
+
file: bundle.file,
|
|
2549
|
+
hash: bundle.hash,
|
|
2550
|
+
schemaHash: supervisor.schemaHash ?? bundle.schemaHash ?? null,
|
|
2551
|
+
mode: bundle.mode ?? null
|
|
2552
|
+
},
|
|
2553
|
+
rooms: infos.map((room, i) => ({ ...room, inspect: inspects[i] ?? null })),
|
|
2554
|
+
metrics: supervisor.metrics(),
|
|
2555
|
+
logs: logs.slice(-LOG_LIMIT).map((e) => ({
|
|
2556
|
+
at: e.at,
|
|
2557
|
+
level: e.level,
|
|
2558
|
+
roomId: e.roomId,
|
|
2559
|
+
args: e.args.map((a) => typeof a === "string" ? a : safeJson(a))
|
|
2560
|
+
}))
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
function safeJson(value) {
|
|
2564
|
+
try {
|
|
2565
|
+
return JSON.stringify(value) ?? String(value);
|
|
2566
|
+
} catch {
|
|
2567
|
+
return String(value);
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
function send(res, status, type, body) {
|
|
2571
|
+
res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
|
|
2572
|
+
res.end(body);
|
|
2573
|
+
}
|
|
2574
|
+
async function startDev(options = {}) {
|
|
2575
|
+
const cwd = path2.resolve(options.cwd ?? process.cwd());
|
|
2576
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
2577
|
+
const entry = resolveEntry(cwd, options.room);
|
|
2578
|
+
const projectId = await readProjectId(cwd);
|
|
2579
|
+
const outDir = path2.join(cwd, ".irtio", "dev");
|
|
2580
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
2581
|
+
await mkdir2(outDir, { recursive: true });
|
|
2582
|
+
const bundleOptions = {
|
|
2583
|
+
entry,
|
|
2584
|
+
outDir,
|
|
2585
|
+
...options.irtioPackages !== void 0 ? { irtioPackages: options.irtioPackages } : {}
|
|
2586
|
+
};
|
|
2587
|
+
const first = await bundleRoom(bundleOptions);
|
|
2588
|
+
for (const warning of first.warnings) log(pc.yellow(`irtio: ${warning}`));
|
|
2589
|
+
const bundle = {
|
|
2590
|
+
file: first.file,
|
|
2591
|
+
hash: first.hash,
|
|
2592
|
+
schemaHash: first.schemaHash,
|
|
2593
|
+
mode: first.mode
|
|
2594
|
+
};
|
|
2595
|
+
const workerEntry = await resolveWorkerEntry2(outDir);
|
|
2596
|
+
const httpHandler = (req, res) => {
|
|
2597
|
+
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
2598
|
+
const route = (req.url ?? "").split("?")[0];
|
|
2599
|
+
if (route === "/__irt" || route === "/__irt/") {
|
|
2600
|
+
send(res, 200, "text/html; charset=utf-8", DEV_PAGE);
|
|
2601
|
+
return true;
|
|
2602
|
+
}
|
|
2603
|
+
if (route === "/__irt/state.json") {
|
|
2604
|
+
void (async () => {
|
|
2605
|
+
try {
|
|
2606
|
+
send(
|
|
2607
|
+
res,
|
|
2608
|
+
200,
|
|
2609
|
+
"application/json; charset=utf-8",
|
|
2610
|
+
JSON.stringify(await buildState(supervisor, projectId, bundle))
|
|
2611
|
+
);
|
|
2612
|
+
} catch (err) {
|
|
2613
|
+
send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
|
|
2614
|
+
}
|
|
2615
|
+
})();
|
|
2616
|
+
return true;
|
|
2617
|
+
}
|
|
2618
|
+
return false;
|
|
2619
|
+
};
|
|
2620
|
+
const supervisor = createSupervisor({
|
|
2621
|
+
projectId,
|
|
2622
|
+
bundlePath: bundle.file,
|
|
2623
|
+
origins: ["*"],
|
|
2624
|
+
port,
|
|
2625
|
+
host: "127.0.0.1",
|
|
2626
|
+
tenantIdleMs: 0,
|
|
2627
|
+
store: new DiskStore(path2.join(cwd, ".irtio", "snapshots")),
|
|
2628
|
+
publicUrl: `http://localhost:${port}`,
|
|
2629
|
+
httpHandler,
|
|
2630
|
+
log: (level, ...args) => {
|
|
2631
|
+
const line = args.map((a) => typeof a === "string" ? a : safeJson(a)).join(" ");
|
|
2632
|
+
log(level === "error" ? pc.red(line) : level === "warn" ? pc.yellow(line) : pc.dim(line));
|
|
2633
|
+
},
|
|
2634
|
+
...workerEntry !== void 0 ? { workerEntry } : {}
|
|
2635
|
+
});
|
|
2636
|
+
try {
|
|
2637
|
+
await supervisor.ready();
|
|
2638
|
+
} catch (err) {
|
|
2639
|
+
const code = err?.code;
|
|
2640
|
+
if (code === "EADDRINUSE" || code === "EACCES") {
|
|
2641
|
+
const why = code === "EADDRINUSE" ? "something is already listening there" : "the OS refused the port (on Windows it may fall inside a reserved range \u2014 `netsh interface ipv4 show excludedportrange protocol=tcp`)";
|
|
2642
|
+
throw new Error(
|
|
2643
|
+
`irtio dev: cannot bind port ${port} \u2014 ${why}.
|
|
2644
|
+
run: irtio dev --port <other port>
|
|
2645
|
+
and point your page at it with window.IRT_URL = 'ws://localhost:<other port>'`
|
|
2646
|
+
);
|
|
2647
|
+
}
|
|
2648
|
+
throw err;
|
|
2649
|
+
}
|
|
2650
|
+
const watcher = options.watch === false ? void 0 : startWatch();
|
|
2651
|
+
let stopped = false;
|
|
2652
|
+
function startWatch() {
|
|
2653
|
+
const dir = path2.dirname(entry);
|
|
2654
|
+
let timer;
|
|
2655
|
+
let rebuilding = Promise.resolve();
|
|
2656
|
+
let handle;
|
|
2657
|
+
try {
|
|
2658
|
+
handle = watch(dir, { recursive: true }, (_event, filename) => {
|
|
2659
|
+
if (filename === null) return;
|
|
2660
|
+
const name = filename.toString();
|
|
2661
|
+
if (name.split(/[\\/]/).some((p) => p === ".irtio" || p === "node_modules")) return;
|
|
2662
|
+
if (!WATCHED_EXTENSIONS.includes(path2.extname(name))) return;
|
|
2663
|
+
if (timer) clearTimeout(timer);
|
|
2664
|
+
timer = setTimeout(() => {
|
|
2665
|
+
timer = void 0;
|
|
2666
|
+
rebuilding = rebuilding.then(rebuild, rebuild);
|
|
2667
|
+
}, DEBOUNCE_MS);
|
|
2668
|
+
timer.unref?.();
|
|
2669
|
+
});
|
|
2670
|
+
} catch (err) {
|
|
2671
|
+
log(pc.yellow(`irtio dev: cannot watch ${dir} (${String(err)}); running without watch`));
|
|
2672
|
+
return void 0;
|
|
2673
|
+
}
|
|
2674
|
+
return handle;
|
|
2675
|
+
}
|
|
2676
|
+
async function rebuild() {
|
|
2677
|
+
if (stopped) return;
|
|
2678
|
+
const started = Date.now();
|
|
2679
|
+
try {
|
|
2680
|
+
const built = await bundleRoom({ ...bundleOptions, verify: false });
|
|
2681
|
+
if (stopped) return;
|
|
2682
|
+
if (built.file === bundle.file) return;
|
|
2683
|
+
for (const warning of built.warnings) log(pc.yellow(`irtio: ${warning}`));
|
|
2684
|
+
await supervisor.reloadBundle(built.file);
|
|
2685
|
+
bundle.file = built.file;
|
|
2686
|
+
bundle.hash = built.hash;
|
|
2687
|
+
log(pc.green(`\u21BB rebuilt ${built.hash.slice(0, 8)} (${Date.now() - started}ms)`));
|
|
2688
|
+
} catch (err) {
|
|
2689
|
+
if (err instanceof BundleError) log(pc.red(err.message));
|
|
2690
|
+
else log(pc.red(`irtio dev: rebuild failed: ${String(err)}`));
|
|
2691
|
+
log(pc.dim("the previous bundle keeps serving"));
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
const boundPort = supervisor.port;
|
|
2695
|
+
return {
|
|
2696
|
+
url: `ws://localhost:${boundPort}`,
|
|
2697
|
+
page: `http://localhost:${boundPort}/__irt/`,
|
|
2698
|
+
port: boundPort,
|
|
2699
|
+
projectId,
|
|
2700
|
+
supervisor,
|
|
2701
|
+
async stop() {
|
|
2702
|
+
if (stopped) return;
|
|
2703
|
+
stopped = true;
|
|
2704
|
+
watcher?.close();
|
|
2705
|
+
await supervisor.flushAll().catch(() => {
|
|
2706
|
+
});
|
|
2707
|
+
await supervisor.close();
|
|
2708
|
+
}
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2711
|
+
function parseDevArgs(args) {
|
|
2712
|
+
const parsed = { watch: true };
|
|
2713
|
+
for (let i = 0; i < args.length; i++) {
|
|
2714
|
+
const arg = args[i];
|
|
2715
|
+
const eq = arg.indexOf("=");
|
|
2716
|
+
const flag = eq === -1 ? arg : arg.slice(0, eq);
|
|
2717
|
+
const inline = eq === -1 ? void 0 : arg.slice(eq + 1);
|
|
2718
|
+
const value = () => {
|
|
2719
|
+
const v = inline ?? args[++i];
|
|
2720
|
+
if (v === void 0) throw new Error(`irtio dev: ${flag} needs a value`);
|
|
2721
|
+
return v;
|
|
2722
|
+
};
|
|
2723
|
+
switch (flag) {
|
|
2724
|
+
case "--room":
|
|
2725
|
+
parsed.room = value();
|
|
2726
|
+
break;
|
|
2727
|
+
case "--port": {
|
|
2728
|
+
const port = Number(value());
|
|
2729
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
2730
|
+
throw new Error("irtio dev: --port must be a port number");
|
|
2731
|
+
}
|
|
2732
|
+
parsed.port = port;
|
|
2733
|
+
break;
|
|
2734
|
+
}
|
|
2735
|
+
case "--no-watch":
|
|
2736
|
+
parsed.watch = false;
|
|
2737
|
+
break;
|
|
2738
|
+
default:
|
|
2739
|
+
throw new Error(`irtio dev: unknown option ${JSON.stringify(arg)}`);
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
return parsed;
|
|
2743
|
+
}
|
|
2744
|
+
async function dev(args) {
|
|
2745
|
+
const parsed = parseDevArgs(args);
|
|
2746
|
+
let server;
|
|
2747
|
+
try {
|
|
2748
|
+
server = await startDev({
|
|
2749
|
+
watch: parsed.watch,
|
|
2750
|
+
...parsed.room !== void 0 ? { room: parsed.room } : {},
|
|
2751
|
+
...parsed.port !== void 0 ? { port: parsed.port } : {}
|
|
2752
|
+
});
|
|
2753
|
+
} catch (err) {
|
|
2754
|
+
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
2755
|
+
process.exitCode = 1;
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
console.log(server.url);
|
|
2759
|
+
console.log(server.page);
|
|
2760
|
+
console.log(
|
|
2761
|
+
pc.dim(
|
|
2762
|
+
`share links: http://localhost:${server.port}/?room=CODE (codes appear when a client joins)`
|
|
2763
|
+
)
|
|
2764
|
+
);
|
|
2765
|
+
let stopping = false;
|
|
2766
|
+
const shutdown = () => {
|
|
2767
|
+
if (stopping) return;
|
|
2768
|
+
stopping = true;
|
|
2769
|
+
void (async () => {
|
|
2770
|
+
await server.stop();
|
|
2771
|
+
process.exit(0);
|
|
2772
|
+
})();
|
|
2773
|
+
};
|
|
2774
|
+
process.on("SIGINT", shutdown);
|
|
2775
|
+
process.on("SIGTERM", shutdown);
|
|
2776
|
+
await new Promise(() => {
|
|
2777
|
+
});
|
|
2778
|
+
}
|
|
2779
|
+
export {
|
|
2780
|
+
dev,
|
|
2781
|
+
parseDevArgs,
|
|
2782
|
+
startDev
|
|
2783
|
+
};
|