@irtio/cli 0.6.0 → 0.8.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/dist/api-keys-UTLYMZYN.js +222 -0
- package/dist/api.js +1 -1
- package/dist/bundle.js +1 -1
- package/dist/{chunk-3HQMVCYA.js → chunk-BQBOBFBO.js} +4 -4
- package/dist/{chunk-RNAH5T4W.js → chunk-IDF46P7R.js} +2 -0
- package/dist/{chunk-DKWG7MGO.js → chunk-JL235KIE.js} +1 -1
- package/dist/{chunk-OTSFRVJN.js → chunk-NOYGY3HA.js} +10 -3
- package/dist/{chunk-ZD4ND6X6.js → chunk-OCVALOGK.js} +1 -1
- package/dist/{chunk-RQSJZWQC.js → chunk-WFMRNGO5.js} +134 -105
- package/dist/{delete-project-VENS2B44.js → delete-project-MXUYNGAO.js} +2 -2
- package/dist/deploy.d.ts +3 -1
- package/dist/deploy.js +164 -6
- package/dist/{dev-QM26ONKS.js → dev-VJ2ATJTF.js} +220 -14
- package/dist/index.js +39 -15
- package/dist/init.js +3 -3
- package/dist/{keys-JHLMEGRA.js → keys-NXRIBJZP.js} +3 -3
- package/dist/{leaderboard-SYPSBPS3.js → leaderboard-ZBJBKETM.js} +65 -11
- package/dist/{login-2M73HBZT.js → login-3RVN5PPN.js} +2 -2
- package/dist/{logs-2W7CPZO5.js → logs-AT7G6YRH.js} +3 -3
- package/dist/{migrate-T3DZJREY.js → migrate-5YCFT63L.js} +4 -4
- package/dist/{ratings-VG32WFDG.js → ratings-XBLX2MUW.js} +3 -3
- package/dist/{rollback-SO74MVZV.js → rollback-LI4TDLQA.js} +3 -3
- package/dist/{rooms-VI33P4RA.js → rooms-52Q5KBUS.js} +179 -20
- package/dist/simulate.d.ts +163 -10
- package/dist/simulate.js +345 -38
- package/dist/{static-deploy-KOWFKWZA.js → static-deploy-7UCYINJB.js} +5 -5
- package/dist/{status-HF3ZEKB7.js → status-JZGKH2P6.js} +3 -3
- package/dist/{usage-4G23QXCH.js → usage-7S447INI.js} +3 -3
- package/dist/{whoami-KTMTQNHM.js → whoami-UFSWPWK6.js} +2 -2
- package/package.json +8 -7
package/dist/deploy.js
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
import {
|
|
2
2
|
logEnsured,
|
|
3
3
|
runStaticDeploy
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-BQBOBFBO.js";
|
|
5
5
|
import {
|
|
6
6
|
clientImportsRoom
|
|
7
7
|
} from "./chunk-TQU6345E.js";
|
|
8
|
-
import "./chunk-
|
|
8
|
+
import "./chunk-WFMRNGO5.js";
|
|
9
9
|
import {
|
|
10
10
|
ensureProject,
|
|
11
11
|
readProjectConfig
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-JL235KIE.js";
|
|
13
13
|
import {
|
|
14
14
|
bundleRoom
|
|
15
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-NOYGY3HA.js";
|
|
16
16
|
import {
|
|
17
17
|
HelpRequested,
|
|
18
18
|
helpFor,
|
|
19
19
|
helpRequested
|
|
20
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-OCVALOGK.js";
|
|
21
21
|
import {
|
|
22
22
|
ApiClientError,
|
|
23
23
|
createApiClient,
|
|
24
24
|
isLoginRequired
|
|
25
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-IDF46P7R.js";
|
|
26
26
|
import {
|
|
27
27
|
resolveControlUrlForUser
|
|
28
28
|
} from "./chunk-UPHQM6NZ.js";
|
|
@@ -37,6 +37,7 @@ import { DEFAULT_ROOM_TYPE, ROOM_TYPE_RE } from "@irtio/protocol";
|
|
|
37
37
|
import { diffSchemas, schemaFromCanonical } from "@irtio/schema";
|
|
38
38
|
import pc from "picocolors";
|
|
39
39
|
var ROOM_CANDIDATES = ["irtio/room.ts", "irtio/room.js", "room.ts"];
|
|
40
|
+
var SCHEMA_CANDIDATES = ["irtio/schema.ts", "irtio/schema.js"];
|
|
40
41
|
var MIGRATIONS_DIR = "irtio/migrations";
|
|
41
42
|
var ROOM_TYPES_DIR = "irtio/rooms";
|
|
42
43
|
var USAGE = `usage: irtio deploy [options]
|
|
@@ -145,12 +146,20 @@ function findRoomEntry(cwd, room) {
|
|
|
145
146
|
}
|
|
146
147
|
return void 0;
|
|
147
148
|
}
|
|
149
|
+
function findSchemaEntry(cwd) {
|
|
150
|
+
for (const candidate of SCHEMA_CANDIDATES) {
|
|
151
|
+
const file = path.resolve(cwd, candidate);
|
|
152
|
+
if (existsSync(file)) return file;
|
|
153
|
+
}
|
|
154
|
+
return void 0;
|
|
155
|
+
}
|
|
148
156
|
function resolveEntry(cwd, room) {
|
|
149
157
|
const entry = findRoomEntry(cwd, room);
|
|
150
158
|
if (entry !== void 0) return entry;
|
|
151
159
|
throw new Error(
|
|
152
160
|
`irtio deploy: no room file found in ${cwd}
|
|
153
161
|
looked for: ${ROOM_CANDIDATES.join(", ")}
|
|
162
|
+
or ${SCHEMA_CANDIDATES.join(" / ")}, to deploy a schema with no room code
|
|
154
163
|
pass --room <file> if it lives elsewhere`
|
|
155
164
|
);
|
|
156
165
|
}
|
|
@@ -235,12 +244,160 @@ async function findMigrationFile(cwd, version) {
|
|
|
235
244
|
return match ? path.join(dir, match) : void 0;
|
|
236
245
|
}
|
|
237
246
|
var DEPLOY_BUILD_DIR = path.join(".irtio", "deploy");
|
|
247
|
+
function isSchemaOnly(cwd, room) {
|
|
248
|
+
if (room !== void 0) return false;
|
|
249
|
+
if (findRoomEntry(cwd, void 0) !== void 0) return false;
|
|
250
|
+
if (existsSync(path.resolve(cwd, ROOM_TYPES_DIR))) return false;
|
|
251
|
+
return findSchemaEntry(cwd) !== void 0;
|
|
252
|
+
}
|
|
253
|
+
async function loadDeployedSchema(entry, outDir, irtioPackages) {
|
|
254
|
+
const bundled = await bundleRoom({
|
|
255
|
+
entry,
|
|
256
|
+
outDir,
|
|
257
|
+
verify: false,
|
|
258
|
+
...irtioPackages !== void 0 ? { irtioPackages } : {}
|
|
259
|
+
});
|
|
260
|
+
const mod = await import(pathToFileURL(bundled.file).href);
|
|
261
|
+
const schema = mod.schema ?? mod.default;
|
|
262
|
+
if (!schema || typeof schema.canonical !== "string" || typeof schema.hash !== "string") {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`irtio deploy: ${path.basename(entry)} does not export a schema
|
|
265
|
+
it must export the value defineSchema(...) returned, as \`schema\` or as the default`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return schema;
|
|
269
|
+
}
|
|
270
|
+
async function runSchemaOnlyDeploy(options, cwd, log, ask, controlUrl, config) {
|
|
271
|
+
const entry = findSchemaEntry(cwd);
|
|
272
|
+
const outDir = path.join(cwd, DEPLOY_BUILD_DIR);
|
|
273
|
+
await mkdir(outDir, { recursive: true });
|
|
274
|
+
const schema = await loadDeployedSchema(entry, outDir, options.irtioPackages);
|
|
275
|
+
log(pc.green(`bundled ${path.relative(cwd, entry)} (schema ${schema.hash.slice(0, 12)})`));
|
|
276
|
+
const client = options.client ?? await createApiClient(controlUrl);
|
|
277
|
+
const projectId = options.project ?? config.project ?? schema.project;
|
|
278
|
+
if (projectId === void 0) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
'irtio deploy: no project id \u2014 pass --project, add "project" to irtio.json, or set it in defineSchema(...)'
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const ensured = await ensureProject(client, projectId, {
|
|
284
|
+
cwd,
|
|
285
|
+
config,
|
|
286
|
+
command: "irtio deploy",
|
|
287
|
+
...options.name !== void 0 ? { name: options.name } : {}
|
|
288
|
+
});
|
|
289
|
+
logEnsured(log, projectId, ensured);
|
|
290
|
+
const deployments = await client.get(`/v1/projects/${projectId}/deployments`);
|
|
291
|
+
const newestOf = (list) => list.reduce(
|
|
292
|
+
(best, d) => best === void 0 || d.version > best.version ? d : best,
|
|
293
|
+
void 0
|
|
294
|
+
);
|
|
295
|
+
const previous = newestOf(deployments.filter((d) => d.rolledBackAt == null));
|
|
296
|
+
const newest = newestOf(deployments);
|
|
297
|
+
const nextVersion = (newest?.version ?? 0) + 1;
|
|
298
|
+
let allowBreaking = options.allowBreaking ?? false;
|
|
299
|
+
let migrationFile;
|
|
300
|
+
if (previous !== void 0) {
|
|
301
|
+
const changes = diffSchemas(schemaFromCanonical(previous.schemaJson), schema);
|
|
302
|
+
const breaking = changes.filter((c) => c.kind === "breaking");
|
|
303
|
+
const additive = changes.filter((c) => c.kind === "additive");
|
|
304
|
+
if (breaking.length > 0) {
|
|
305
|
+
if (!allowBreaking) {
|
|
306
|
+
const hint = "run: irtio migrate create <name>";
|
|
307
|
+
printBreaking(log, breaking, hint);
|
|
308
|
+
throw new DeployRefusedError({ changes: breaking, hint });
|
|
309
|
+
}
|
|
310
|
+
migrationFile = await findMigrationFile(cwd, nextVersion);
|
|
311
|
+
if (migrationFile === void 0) {
|
|
312
|
+
const hint = `--allow-breaking needs a migration for v${nextVersion}. Run: irtio migrate create <name>`;
|
|
313
|
+
printBreaking(log, breaking, hint);
|
|
314
|
+
throw new DeployRefusedError({ changes: breaking, hint });
|
|
315
|
+
}
|
|
316
|
+
log(pc.yellow(`${breaking.length} breaking change(s), proceeding with --allow-breaking:`));
|
|
317
|
+
for (const change of breaking) log(` ${change.message}`);
|
|
318
|
+
} else {
|
|
319
|
+
allowBreaking = false;
|
|
320
|
+
}
|
|
321
|
+
if (additive.length > 0) {
|
|
322
|
+
log(pc.cyan(`${additive.length} additive change(s):`));
|
|
323
|
+
for (const change of additive) log(` ${change.message}`);
|
|
324
|
+
}
|
|
325
|
+
if (changes.length === 0) log(pc.dim("no schema changes"));
|
|
326
|
+
} else {
|
|
327
|
+
log(pc.dim("no previous deployment, so this is the first deploy for this project"));
|
|
328
|
+
}
|
|
329
|
+
let migrationKey;
|
|
330
|
+
if (migrationFile !== void 0) {
|
|
331
|
+
const migrationBundle = await bundleRoom({
|
|
332
|
+
entry: migrationFile,
|
|
333
|
+
outDir,
|
|
334
|
+
verify: false,
|
|
335
|
+
...options.irtioPackages !== void 0 ? { irtioPackages: options.irtioPackages } : {}
|
|
336
|
+
});
|
|
337
|
+
const uploaded = await client.postBytes(
|
|
338
|
+
`/v1/projects/${projectId}/artifacts`,
|
|
339
|
+
new Uint8Array(await readFile(migrationBundle.file))
|
|
340
|
+
);
|
|
341
|
+
migrationKey = uploaded.key;
|
|
342
|
+
log(pc.green(`bundled and uploaded migration ${path.basename(migrationFile)}`));
|
|
343
|
+
}
|
|
344
|
+
let origin;
|
|
345
|
+
if (previous === void 0) {
|
|
346
|
+
const answer = (await ask("production origin (localhost is always allowed) \u2014 leave blank to skip: ")).trim();
|
|
347
|
+
if (answer.length > 0) origin = answer;
|
|
348
|
+
}
|
|
349
|
+
let created;
|
|
350
|
+
try {
|
|
351
|
+
created = await client.post(`/v1/projects/${projectId}/deployments`, {
|
|
352
|
+
// No `bundleHash` and no `bundleKey`: that absence IS the schema-only deploy, and it is
|
|
353
|
+
// what makes control write a row `isRelayOnly` reads as relay-only.
|
|
354
|
+
schemaJson: schema.canonical,
|
|
355
|
+
schemaHash: schema.hash,
|
|
356
|
+
...migrationKey !== void 0 ? { migrationKey } : {},
|
|
357
|
+
...allowBreaking ? { allowBreaking: true } : {},
|
|
358
|
+
...origin !== void 0 ? { origin } : {}
|
|
359
|
+
});
|
|
360
|
+
} catch (err) {
|
|
361
|
+
if (err instanceof ApiClientError && err.code === "E_BREAKING_SCHEMA" && err.changes) {
|
|
362
|
+
const changes = err.changes;
|
|
363
|
+
const hint = err.hint ?? "run: irtio migrate create <name>";
|
|
364
|
+
printBreaking(log, changes, hint);
|
|
365
|
+
throw new DeployRefusedError({ changes, hint });
|
|
366
|
+
}
|
|
367
|
+
if (isLoginRequired(err)) throw err;
|
|
368
|
+
throw err;
|
|
369
|
+
}
|
|
370
|
+
await rm(outDir, { recursive: true, force: true }).catch(() => {
|
|
371
|
+
});
|
|
372
|
+
log(pc.green(`deployed v${created.version}`));
|
|
373
|
+
log(pc.dim("schema-only deploy: this project runs as a relay tenant"));
|
|
374
|
+
log(pc.dim(`reachable at ${created.url}`));
|
|
375
|
+
const result = {
|
|
376
|
+
project: projectId,
|
|
377
|
+
version: created.version,
|
|
378
|
+
url: created.url,
|
|
379
|
+
draining: created.draining,
|
|
380
|
+
...created.applied !== void 0 ? { applied: created.applied } : {}
|
|
381
|
+
};
|
|
382
|
+
if (config.static === void 0 || options.noStatic === true) return result;
|
|
383
|
+
const site = await runStaticDeploy({
|
|
384
|
+
cwd,
|
|
385
|
+
...options.config !== void 0 ? { config: options.config } : {},
|
|
386
|
+
...options.controlUrl !== void 0 ? { controlUrl: options.controlUrl } : {},
|
|
387
|
+
...options.client !== void 0 ? { client: options.client } : {},
|
|
388
|
+
log
|
|
389
|
+
});
|
|
390
|
+
return { ...result, site };
|
|
391
|
+
}
|
|
238
392
|
async function runDeploy(options = {}) {
|
|
239
393
|
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
240
394
|
const log = options.log ?? ((line) => console.log(line));
|
|
241
395
|
const ask = options.ask ?? defaultAsk;
|
|
242
396
|
const controlUrl = await resolveControlUrlForUser(options.controlUrl);
|
|
243
397
|
const config = await readProjectConfig(cwd, "irtio deploy", options.config);
|
|
398
|
+
if (isSchemaOnly(cwd, options.room)) {
|
|
399
|
+
return runSchemaOnlyDeploy(options, cwd, log, ask, controlUrl, config);
|
|
400
|
+
}
|
|
244
401
|
const roomTypes = findRoomTypes(cwd, options.room);
|
|
245
402
|
const entry = roomTypes.default;
|
|
246
403
|
await warnIfClientImportsRoom(cwd, entry, config, log);
|
|
@@ -562,6 +719,7 @@ export {
|
|
|
562
719
|
deploy,
|
|
563
720
|
findRoomEntry,
|
|
564
721
|
findRoomTypes,
|
|
722
|
+
findSchemaEntry,
|
|
565
723
|
parseDeployArgs,
|
|
566
724
|
runDeploy
|
|
567
725
|
};
|
|
@@ -23,20 +23,20 @@ import {
|
|
|
23
23
|
ratingValueProblem,
|
|
24
24
|
saveKey,
|
|
25
25
|
verifyAssertion
|
|
26
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-WFMRNGO5.js";
|
|
27
27
|
import {
|
|
28
28
|
readProjectConfig
|
|
29
|
-
} from "./chunk-
|
|
29
|
+
} from "./chunk-JL235KIE.js";
|
|
30
30
|
import {
|
|
31
31
|
BundleError,
|
|
32
32
|
bundleRoom
|
|
33
|
-
} from "./chunk-
|
|
33
|
+
} from "./chunk-NOYGY3HA.js";
|
|
34
34
|
import {
|
|
35
35
|
HelpRequested,
|
|
36
36
|
helpFor,
|
|
37
37
|
helpRequested
|
|
38
|
-
} from "./chunk-
|
|
39
|
-
import "./chunk-
|
|
38
|
+
} from "./chunk-OCVALOGK.js";
|
|
39
|
+
import "./chunk-IDF46P7R.js";
|
|
40
40
|
import "./chunk-UPHQM6NZ.js";
|
|
41
41
|
|
|
42
42
|
// src/dev.ts
|
|
@@ -187,9 +187,11 @@ import {
|
|
|
187
187
|
isMailboxAlarm,
|
|
188
188
|
isOutboxAlarm,
|
|
189
189
|
isRelayHash8,
|
|
190
|
+
isTypedMsg,
|
|
190
191
|
isVoiceMsg,
|
|
191
192
|
relaySchema as relaySchema2,
|
|
192
193
|
schemaPayload,
|
|
194
|
+
typedMsgFromClientOk,
|
|
193
195
|
withBuiltins
|
|
194
196
|
} from "@irtio/protocol";
|
|
195
197
|
import { inspectState } from "@irtio/runtime";
|
|
@@ -402,10 +404,12 @@ function relayForward(room, peer, type, payload) {
|
|
|
402
404
|
}
|
|
403
405
|
let target;
|
|
404
406
|
let body;
|
|
407
|
+
let typed;
|
|
405
408
|
try {
|
|
406
409
|
const msg = decodeMsg(payload);
|
|
407
410
|
target = msg.target;
|
|
408
411
|
body = msg.payload;
|
|
412
|
+
typed = msg.typed;
|
|
409
413
|
} catch {
|
|
410
414
|
return { ok: false, reason: "malformed MSG" };
|
|
411
415
|
}
|
|
@@ -415,7 +419,11 @@ function relayForward(room, peer, type, payload) {
|
|
|
415
419
|
if (target.kind === "voice") return { ok: true, dropped: true };
|
|
416
420
|
const out = encodeFrame(
|
|
417
421
|
FrameType.MSG,
|
|
418
|
-
encodeMsg({
|
|
422
|
+
encodeMsg({
|
|
423
|
+
target: { kind: "client", clientId: peer.clientId },
|
|
424
|
+
payload: body,
|
|
425
|
+
...typed ? { typed } : {}
|
|
426
|
+
})
|
|
419
427
|
);
|
|
420
428
|
for (const other of room.peers()) {
|
|
421
429
|
if (other === peer || other.state !== "joined") continue;
|
|
@@ -1738,6 +1746,33 @@ var RoomRecord = class {
|
|
|
1738
1746
|
* which is the arena case and the common one.
|
|
1739
1747
|
*/
|
|
1740
1748
|
backfillOpen = true;
|
|
1749
|
+
// ---- M6 lane H: lobby front door ----
|
|
1750
|
+
/**
|
|
1751
|
+
* D75: whether this room is currently in control's public registry, and which queue under.
|
|
1752
|
+
*
|
|
1753
|
+
* On the record for `backfillOpen`'s reasons — out of the hibernation blob, and the supervisor's
|
|
1754
|
+
* own bookkeeping — but NOT reset on a worker ready, and that difference is the whole point.
|
|
1755
|
+
* `lobbyStarted` is a one-way door: a room whose game has started must never be advertised
|
|
1756
|
+
* again, and a reset would re-offer it to a stranger on the first poll after a wake.
|
|
1757
|
+
*/
|
|
1758
|
+
lobbyPublic = false;
|
|
1759
|
+
lobbyQueue;
|
|
1760
|
+
lobbyStarted = false;
|
|
1761
|
+
/**
|
|
1762
|
+
* Has this room said anything about its lobby yet?
|
|
1763
|
+
*
|
|
1764
|
+
* This is the difference between "private" and "not in the registry conversation", and getting it
|
|
1765
|
+
* wrong was a real bug rather than a nicety. The first version reported the lobby fields only
|
|
1766
|
+
* when `lobbyPublic` was true, so a `false` never left the box: control's deregister branch was
|
|
1767
|
+
* unreachable, and a host who made a lobby public and then private kept receiving strangers while
|
|
1768
|
+
* their own panel said private.
|
|
1769
|
+
*
|
|
1770
|
+
* The two states still have to stay apart, because absence is what an agent older than this lane
|
|
1771
|
+
* sends for every room on its box and what every room without a lobby sends forever — control
|
|
1772
|
+
* reads it as "leave this room's registry row alone". So the latch: once a room has spoken, every
|
|
1773
|
+
* report carries its current answer, `false` included.
|
|
1774
|
+
*/
|
|
1775
|
+
lobbySpoken = false;
|
|
1741
1776
|
/**
|
|
1742
1777
|
* The deployment version this room was created under. New rooms take the newest; rooms created
|
|
1743
1778
|
* before a deploy keep theirs until they idle (per-room drain, plan §3.3).
|
|
@@ -1901,7 +1936,18 @@ var RoomRecord = class {
|
|
|
1901
1936
|
// only when the room type declared it, so "absent" and "not a candidate" stay the same
|
|
1902
1937
|
// thing all the way to control's nullable column.
|
|
1903
1938
|
maxClients: this.config.maxClients,
|
|
1904
|
-
...this.config.backfill === true ? { backfill: this.backfillNow } : {}
|
|
1939
|
+
...this.config.backfill === true ? { backfill: this.backfillNow } : {},
|
|
1940
|
+
// ---- M6 lane H: lobby front door ----
|
|
1941
|
+
// Reported by a room that has said something about its lobby, and by no other room, so
|
|
1942
|
+
// "absent" stays "not in the registry conversation at all" — the same shape `backfill` uses,
|
|
1943
|
+
// and what keeps a mixed fleet degrading to "no public rooms from the old boxes".
|
|
1944
|
+
//
|
|
1945
|
+
// Once a room HAS spoken, its current answer goes out on every report including `false`. See
|
|
1946
|
+
// `lobbySpoken` for the bug that came of only ever reporting `true`.
|
|
1947
|
+
...this.lobbyStarted ? { lobbyStarted: true } : this.lobbySpoken ? {
|
|
1948
|
+
public: this.lobbyPublic,
|
|
1949
|
+
...this.lobbyQueue !== void 0 ? { queue: this.lobbyQueue } : {}
|
|
1950
|
+
} : {}
|
|
1905
1951
|
};
|
|
1906
1952
|
}
|
|
1907
1953
|
};
|
|
@@ -2441,6 +2487,7 @@ var IDENTITY_KEY_WAIT_MS = 2e3;
|
|
|
2441
2487
|
var MAX_IDENTITY_KEY_WAITERS = 64;
|
|
2442
2488
|
var RESUME_TOKEN_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
2443
2489
|
var BUS_SEEN_MAX = 256;
|
|
2490
|
+
var LOBBY_QUEUE_RE = /^[a-z0-9][a-z0-9._-]{0,31}$/;
|
|
2444
2491
|
var SHARD_ASSIGNMENT_RETRY_MS = [1e3, 3e4];
|
|
2445
2492
|
var KvUnavailable = class extends Error {
|
|
2446
2493
|
code = KV_ERRORS.unavailable;
|
|
@@ -3008,9 +3055,29 @@ var SupervisorImpl = class {
|
|
|
3008
3055
|
*
|
|
3009
3056
|
* They go into a ring under the reserved id `__tenant` (D38), so the whole existing pipeline
|
|
3010
3057
|
* carries them with no new route and no new frame.
|
|
3058
|
+
*
|
|
3059
|
+
* bug #53 (D67-g, which recorded the gap rather than closing it): on a **sharded** tenant every
|
|
3060
|
+
* ring line is tagged `[shard n]`. Every shard of a project ships its `__tenant` ring under the
|
|
3061
|
+
* same project and the same reserved room id, `log_events` has no shard column, and control
|
|
3062
|
+
* drops the frame's shard before the insert, so without the tag `irtio logs` shows two shards'
|
|
3063
|
+
* boot lines interleaved by time and calls them one restart. The tag is written here, at the
|
|
3064
|
+
* source, because this process is the only thing that knows which shard it is without being
|
|
3065
|
+
* told, and one string in the message beats a schema change on an append-only table.
|
|
3066
|
+
*
|
|
3067
|
+
* It is the mirror of `roomLog` below, and for the same reason: a ring line carries whatever its
|
|
3068
|
+
* stream's identity does not. A room's ring is keyed by the room, so its lines stay bare and the
|
|
3069
|
+
* console gets the `[room]` prefix; the tenant ring is keyed by `__tenant` alone, so its lines
|
|
3070
|
+
* carry the shard and the console stays bare — each shard is its own VM with its own console
|
|
3071
|
+
* file, which already says which shard is talking.
|
|
3072
|
+
*
|
|
3073
|
+
* A single-shard tenant (every tenant that exists today) takes the same bare `args` it always
|
|
3074
|
+
* did, byte for byte: `single-shard-pin.test.ts` holds that.
|
|
3011
3075
|
*/
|
|
3012
3076
|
log(level, ...args) {
|
|
3013
|
-
this.tenantLogs.push(
|
|
3077
|
+
this.tenantLogs.push(
|
|
3078
|
+
level,
|
|
3079
|
+
this.isSharded() ? [`[shard ${this.config.shard ?? 0}]`, ...args] : args
|
|
3080
|
+
);
|
|
3014
3081
|
this.emit(level, args);
|
|
3015
3082
|
}
|
|
3016
3083
|
/** The console/config sink, with no ring behind it. */
|
|
@@ -3047,9 +3114,20 @@ var SupervisorImpl = class {
|
|
|
3047
3114
|
* shape, so a dev tenant's existing snapshots stay readable.
|
|
3048
3115
|
*/
|
|
3049
3116
|
/**
|
|
3050
|
-
* The
|
|
3051
|
-
*
|
|
3052
|
-
*
|
|
3117
|
+
* The admin API the host agent polls from outside the VM (week 5). It exists only when
|
|
3118
|
+
* `adminToken` is configured.
|
|
3119
|
+
*
|
|
3120
|
+
* Its original rule was that every route is a projection of `SupervisorAdmin` and the agent must
|
|
3121
|
+
* not be able to *change* anything about a tenant it is shipping telemetry for. That rule now
|
|
3122
|
+
* has five exceptions, each added deliberately and each documented at its own `case`:
|
|
3123
|
+
* `/admin/resume` (D31, the post-restore wall clock), `/admin/deploy` (the in-place deploy),
|
|
3124
|
+
* `/admin/caps` (D48), `/admin/identity-keys` (D53), `/admin/bus/deliver` (D67-e) and
|
|
3125
|
+
* `/admin/rooms/evict` (M6 lane A, D68-d). The surviving half of the rule is the part that
|
|
3126
|
+
* matters and it is unchanged: every one of them is a **push from control**, arriving over the
|
|
3127
|
+
* fleet socket and relayed by the agent with the derived admin token. None of them is something
|
|
3128
|
+
* a guest, a tenant or anything else that can reach this port may originate.
|
|
3129
|
+
*
|
|
3130
|
+
* `/admin/rooms/evict` is the one that disconnects players, so it is the one to read carefully.
|
|
3053
3131
|
*/
|
|
3054
3132
|
serveAdmin(req, res) {
|
|
3055
3133
|
const token = this.config.adminToken;
|
|
@@ -3317,6 +3395,55 @@ var SupervisorImpl = class {
|
|
|
3317
3395
|
});
|
|
3318
3396
|
return true;
|
|
3319
3397
|
}
|
|
3398
|
+
// ---- M6 lane A: rooms API ----
|
|
3399
|
+
// D68-d: the FIFTH write on this surface, and the only one that disconnects players. A
|
|
3400
|
+
// `DELETE ?force=true` on the rooms API reached control, control authorized it, and this is
|
|
3401
|
+
// control telling the guest to let the room go before the objects are removed underneath it.
|
|
3402
|
+
//
|
|
3403
|
+
// Same `adminToken` gate as every other route here, which is the whole access story: the
|
|
3404
|
+
// token is derived per project from the agent's own fleet token and never leaves the box, so
|
|
3405
|
+
// a tenant cannot forge it, and the frame that triggers this is `ControlToAgent` — a
|
|
3406
|
+
// direction a guest cannot speak on the fleet socket at all.
|
|
3407
|
+
//
|
|
3408
|
+
// Answers `{ outcome }`, never an error for "not here": a room this tenant is not holding is
|
|
3409
|
+
// `absent`, and control deletes the family either way.
|
|
3410
|
+
case "/admin/rooms/evict": {
|
|
3411
|
+
if (req.method !== "POST") {
|
|
3412
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
3413
|
+
res.end('{"error":"POST only"}');
|
|
3414
|
+
return true;
|
|
3415
|
+
}
|
|
3416
|
+
let body = "";
|
|
3417
|
+
req.on("data", (d) => {
|
|
3418
|
+
body += d.toString();
|
|
3419
|
+
});
|
|
3420
|
+
req.on("end", () => {
|
|
3421
|
+
void (async () => {
|
|
3422
|
+
let parsed;
|
|
3423
|
+
try {
|
|
3424
|
+
parsed = JSON.parse(body || "{}");
|
|
3425
|
+
} catch {
|
|
3426
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3427
|
+
res.end('{"error":"body is not JSON"}');
|
|
3428
|
+
return;
|
|
3429
|
+
}
|
|
3430
|
+
if (typeof parsed.roomId !== "string" || parsed.roomId.length === 0) {
|
|
3431
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3432
|
+
res.end('{"error":"roomId must be a non-empty string"}');
|
|
3433
|
+
return;
|
|
3434
|
+
}
|
|
3435
|
+
try {
|
|
3436
|
+
json({ outcome: await this.evictRoom(parsed.roomId) });
|
|
3437
|
+
} catch (err) {
|
|
3438
|
+
this.log("error", "admin room evict failed", err);
|
|
3439
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
3440
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
3441
|
+
}
|
|
3442
|
+
})();
|
|
3443
|
+
});
|
|
3444
|
+
return true;
|
|
3445
|
+
}
|
|
3446
|
+
// ---- end M6 lane A ----
|
|
3320
3447
|
case "/admin/caps": {
|
|
3321
3448
|
if (req.method === "GET") {
|
|
3322
3449
|
const state = this.caps.current();
|
|
@@ -4403,7 +4530,7 @@ var SupervisorImpl = class {
|
|
|
4403
4530
|
message: `${call.playerId} is not a client of room ${room.id}; leaderboard submits are only accepted for players currently in the room`
|
|
4404
4531
|
};
|
|
4405
4532
|
}
|
|
4406
|
-
await this.requireLeaderboard().submit(call.board, call.playerId, call.score);
|
|
4533
|
+
await this.requireLeaderboard().submit(call.board, call.playerId, call.score, call.bucket);
|
|
4407
4534
|
return { ok: true };
|
|
4408
4535
|
}
|
|
4409
4536
|
case "ratingReport": {
|
|
@@ -5106,6 +5233,43 @@ var SupervisorImpl = class {
|
|
|
5106
5233
|
});
|
|
5107
5234
|
this.roomLog(room, "info", `room closed: ${message}`);
|
|
5108
5235
|
}
|
|
5236
|
+
// ---- M6 lane A: rooms API ----
|
|
5237
|
+
/**
|
|
5238
|
+
* M6 lane A (D68-d): stop one room and remove its stored keys, because control is about to
|
|
5239
|
+
* delete the rest of its family.
|
|
5240
|
+
*
|
|
5241
|
+
* Answers `'absent'` when this tenant is not holding the room, which is not a failure and is
|
|
5242
|
+
* most of the real cases: the room went to sleep, or this tenant restarted, and control's row
|
|
5243
|
+
* was a poll behind. Control deletes the family either way.
|
|
5244
|
+
*
|
|
5245
|
+
* **The race this exists to close, and why the `await` is the fix.** `closeRoom` already deletes
|
|
5246
|
+
* the live key and the alarm sidecar, and refuses to run twice. What it cannot do on its own is
|
|
5247
|
+
* stop a hibernation that is *already in flight*: by then `hibernateRoom` has read the worker's
|
|
5248
|
+
* snapshot and is inside `putSnapshotWithRetry`, so its `store.put` lands after `closeRoom`'s
|
|
5249
|
+
* `store.delete` and after control's `deleteRoomFamily` — and the room is resurrected as a live
|
|
5250
|
+
* key nothing will ever delete again, because its row is gone, so the reaper cannot see it and
|
|
5251
|
+
* `irtio rooms` cannot list it. Awaiting `room.transition` first removes that window: every path
|
|
5252
|
+
* that can write a snapshot (`hibernateRoom`, `hibernateRelay`, `migrateRoom`, `wakeRoom`)
|
|
5253
|
+
* publishes itself there and settles before the deletes run, and no NEW one can start, because
|
|
5254
|
+
* they all early-return on a state that is not `running` and `closeRoom` has set `closed`.
|
|
5255
|
+
*
|
|
5256
|
+
* The two deletes are awaited, unlike `closeRoom`'s own fire-and-forget pair, for the same
|
|
5257
|
+
* reason: control lists the bucket the instant this answers.
|
|
5258
|
+
*
|
|
5259
|
+
* `flushAll` (tenant idle) needs no guard of its own: `closeRoom` removes the record from the
|
|
5260
|
+
* registry and the flush walks the registry.
|
|
5261
|
+
*/
|
|
5262
|
+
async evictRoom(roomId) {
|
|
5263
|
+
const room = this.registry.get(roomId);
|
|
5264
|
+
if (room === void 0) return "absent";
|
|
5265
|
+
await room.transition.catch(() => void 0);
|
|
5266
|
+
this.closeRoom(room, "E_ROOM_DELETED", `room ${roomId} was deleted by an operator`);
|
|
5267
|
+
const key = this.storeKey(room);
|
|
5268
|
+
await this.store.delete(key).catch(() => void 0);
|
|
5269
|
+
await this.store.delete(alarmsKey(key)).catch(() => void 0);
|
|
5270
|
+
return "evicted";
|
|
5271
|
+
}
|
|
5272
|
+
// ---- end M6 lane A ----
|
|
5109
5273
|
// -------------------------------------------------------------------------
|
|
5110
5274
|
// Joining
|
|
5111
5275
|
// -------------------------------------------------------------------------
|
|
@@ -5351,6 +5515,9 @@ var SupervisorImpl = class {
|
|
|
5351
5515
|
this.handleVoiceFrame(room, session, payload.subarray(1));
|
|
5352
5516
|
return;
|
|
5353
5517
|
}
|
|
5518
|
+
if (type === FrameType3.MSG && isTypedMsg(payload) && !typedMsgFromClientOk(payload)) {
|
|
5519
|
+
return;
|
|
5520
|
+
}
|
|
5354
5521
|
if (room.relay) {
|
|
5355
5522
|
this.relayFrame(room, session, type, payload);
|
|
5356
5523
|
return;
|
|
@@ -5551,6 +5718,38 @@ var SupervisorImpl = class {
|
|
|
5551
5718
|
case "setBackfill":
|
|
5552
5719
|
room.backfillOpen = msg.open;
|
|
5553
5720
|
return;
|
|
5721
|
+
// ---- M6 lane H: lobby front door ----
|
|
5722
|
+
case "setLobby": {
|
|
5723
|
+
if (room.config.lobby !== true) {
|
|
5724
|
+
this.roomLog(
|
|
5725
|
+
room,
|
|
5726
|
+
"warn",
|
|
5727
|
+
"refusing setLobby: this room type declared no lobby, so it cannot enter the public registry"
|
|
5728
|
+
);
|
|
5729
|
+
return;
|
|
5730
|
+
}
|
|
5731
|
+
if (msg.started === true) {
|
|
5732
|
+
room.lobbySpoken = true;
|
|
5733
|
+
room.lobbyStarted = true;
|
|
5734
|
+
room.lobbyPublic = false;
|
|
5735
|
+
return;
|
|
5736
|
+
}
|
|
5737
|
+
if (room.lobbyStarted) return;
|
|
5738
|
+
if (msg.queue !== void 0) {
|
|
5739
|
+
if (!LOBBY_QUEUE_RE.test(msg.queue)) {
|
|
5740
|
+
this.roomLog(
|
|
5741
|
+
room,
|
|
5742
|
+
"warn",
|
|
5743
|
+
`refusing setLobby: queue ${JSON.stringify(msg.queue)} must be 1-32 of a-z 0-9 . _ - and start with a letter or digit`
|
|
5744
|
+
);
|
|
5745
|
+
return;
|
|
5746
|
+
}
|
|
5747
|
+
room.lobbyQueue = msg.queue;
|
|
5748
|
+
}
|
|
5749
|
+
room.lobbySpoken = true;
|
|
5750
|
+
if (msg.public !== void 0) room.lobbyPublic = msg.public;
|
|
5751
|
+
return;
|
|
5752
|
+
}
|
|
5554
5753
|
case "setAlarm":
|
|
5555
5754
|
if (isMailboxAlarm(msg.name)) {
|
|
5556
5755
|
this.roomLog(
|
|
@@ -5735,6 +5934,7 @@ var SupervisorImpl = class {
|
|
|
5735
5934
|
room.metrics.ticks = stats.ticks;
|
|
5736
5935
|
room.metrics.maxTickMs = stats.maxTickMs;
|
|
5737
5936
|
room.metrics.overruns = stats.overruns;
|
|
5937
|
+
room.metrics.messagesDropped = stats.messagesDropped;
|
|
5738
5938
|
if (msg.heap !== void 0) {
|
|
5739
5939
|
this.recordHeap(room, msg.heap.usedBytes, msg.heap.limitBytes);
|
|
5740
5940
|
}
|
|
@@ -6142,7 +6342,7 @@ async function resolveWorkerEntry2(outDir) {
|
|
|
6142
6342
|
}
|
|
6143
6343
|
const outfile = path.join(outDir, "worker.mjs");
|
|
6144
6344
|
const runtimeDir = path.join(packagesDir, "runtime");
|
|
6145
|
-
const enginePlugins = ["@dimforge/rapier3d-compat", "matter-js"].map((pkg) => engineAbsolutePlugin(runtimeDir, pkg)).filter((p) => p !== void 0);
|
|
6345
|
+
const enginePlugins = ["@dimforge/rapier3d-compat", "matter-js", "@dimforge/rapier2d-compat"].map((pkg) => engineAbsolutePlugin(runtimeDir, pkg)).filter((p) => p !== void 0);
|
|
6146
6346
|
await esbuild.build({
|
|
6147
6347
|
entryPoints: [source],
|
|
6148
6348
|
bundle: true,
|
|
@@ -6161,7 +6361,13 @@ async function resolveWorkerEntry2(outDir) {
|
|
|
6161
6361
|
// bundle resolves via node_modules. `rapierPlugin` (above) already externalizes it at an
|
|
6162
6362
|
// absolute path when `@irtio/runtime`'s own copy can be found; this string entry is the
|
|
6163
6363
|
// fallback for a published install where that resolution comes for free from node_modules.
|
|
6164
|
-
external: [
|
|
6364
|
+
external: [
|
|
6365
|
+
"node:worker_threads",
|
|
6366
|
+
"node:url",
|
|
6367
|
+
"@dimforge/rapier3d-compat",
|
|
6368
|
+
"matter-js",
|
|
6369
|
+
"@dimforge/rapier2d-compat"
|
|
6370
|
+
]
|
|
6165
6371
|
});
|
|
6166
6372
|
return outfile;
|
|
6167
6373
|
}
|