@irtio/cli 0.1.0 → 0.3.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/bundle.d.ts +9 -1
- package/dist/bundle.js +3 -1
- package/dist/chunk-BPE452KF.js +180 -0
- package/dist/{chunk-J3G6AUJY.js → chunk-I37DLT7K.js} +1 -1
- package/dist/{chunk-ZWCLCYCS.js → chunk-KRQUAEN2.js} +35 -6
- package/dist/{chunk-UYN5PWLT.js → chunk-NVUKSP5U.js} +3 -1
- package/dist/chunk-TQU6345E.js +36 -0
- package/dist/{deploy-4W6AYYEW.js → deploy-6OM3UYNL.js} +93 -15
- package/dist/{dev-BFWFWJ4J.js → dev-LOSTB4CD.js} +1095 -114
- package/dist/index.js +46 -10
- package/dist/init.d.ts +3 -0
- package/dist/init.js +236 -8
- package/dist/keys-KEKO3EJ6.js +174 -0
- package/dist/{login-US2YT5ZB.js → login-OV2EFNTJ.js} +26 -5
- package/dist/{logs-6GLBO3TP.js → logs-5OUDPAIX.js} +11 -3
- package/dist/{migrate-FQIDF747.js → migrate-3R6VRW4J.js} +7 -5
- package/dist/rollback-CLQVYFHW.js +122 -0
- package/dist/rooms-OJ3JLYHD.js +199 -0
- package/dist/simulate.d.ts +42 -1
- package/dist/simulate.js +129 -6
- package/dist/static-deploy-2LB6H54Y.js +199 -0
- package/dist/{whoami-5Q32SXFX.js → whoami-S73O6KJF.js} +2 -2
- package/package.json +9 -7
- package/dist/rooms-JXT3PRHN.js +0 -102
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createApiClient,
|
|
3
3
|
isLoginRequired
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-I37DLT7K.js";
|
|
5
5
|
import {
|
|
6
6
|
resolveControlUrlForUser
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-NVUKSP5U.js";
|
|
8
8
|
|
|
9
9
|
// src/logs.ts
|
|
10
10
|
import { existsSync } from "fs";
|
|
@@ -28,6 +28,12 @@ function parseLogsArgs(args) {
|
|
|
28
28
|
parsed.since = value;
|
|
29
29
|
break;
|
|
30
30
|
}
|
|
31
|
+
case "--room": {
|
|
32
|
+
const value = args[++i];
|
|
33
|
+
if (value === void 0) throw new Error("irtio logs: --room needs a value");
|
|
34
|
+
parsed.room = value;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
31
37
|
case "--project": {
|
|
32
38
|
const value = args[++i];
|
|
33
39
|
if (value === void 0) throw new Error("irtio logs: --project needs a value");
|
|
@@ -63,7 +69,8 @@ async function runLogs(options) {
|
|
|
63
69
|
let polls = 0;
|
|
64
70
|
for (; ; ) {
|
|
65
71
|
const rows = await client.get(`/v1/projects/${options.project}/logs`, {
|
|
66
|
-
since
|
|
72
|
+
since,
|
|
73
|
+
...options.room !== void 0 ? { room: options.room } : {}
|
|
67
74
|
});
|
|
68
75
|
for (const row of rows) log(formatRow(row));
|
|
69
76
|
const last = rows[rows.length - 1];
|
|
@@ -101,6 +108,7 @@ async function logs(args, deps = {}) {
|
|
|
101
108
|
follow: parsed.follow,
|
|
102
109
|
log,
|
|
103
110
|
...parsed.since !== void 0 ? { since: parsed.since } : {},
|
|
111
|
+
...parsed.room !== void 0 ? { room: parsed.room } : {},
|
|
104
112
|
...parsed.url !== void 0 ? { controlUrl: parsed.url } : {},
|
|
105
113
|
...deps.client !== void 0 ? { client: deps.client } : {}
|
|
106
114
|
});
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
bundleRoom
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-KRQUAEN2.js";
|
|
4
4
|
import {
|
|
5
5
|
createApiClient,
|
|
6
6
|
isLoginRequired
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-I37DLT7K.js";
|
|
8
8
|
import {
|
|
9
9
|
resolveControlUrlForUser
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-NVUKSP5U.js";
|
|
11
11
|
|
|
12
12
|
// src/migrate.ts
|
|
13
13
|
import { existsSync } from "fs";
|
|
@@ -101,8 +101,10 @@ export function up(state: MigrationState, s: MigrationHelpers): MigrationState {
|
|
|
101
101
|
return state;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
// \`down\` is scaffolded and stored but
|
|
105
|
-
//
|
|
104
|
+
// \`down\` is scaffolded and stored, but nothing calls it. \`irtio rollback\` does NOT run it: it
|
|
105
|
+
// restores the pre-migration save generation the \`migrate\` deploy wrote, which is how it can
|
|
106
|
+
// undo a migration you never wrote a \`down\` for. Fill this in only if you want the inverse
|
|
107
|
+
// transform recorded next to the forward one.
|
|
106
108
|
//
|
|
107
109
|
// export function down(state: MigrationState, s: MigrationHelpers): MigrationState {
|
|
108
110
|
// return state;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createApiClient,
|
|
3
|
+
isLoginRequired
|
|
4
|
+
} from "./chunk-I37DLT7K.js";
|
|
5
|
+
import {
|
|
6
|
+
resolveControlUrlForUser
|
|
7
|
+
} from "./chunk-NVUKSP5U.js";
|
|
8
|
+
|
|
9
|
+
// src/rollback.ts
|
|
10
|
+
import { existsSync } from "fs";
|
|
11
|
+
import { readFile } from "fs/promises";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import pc from "picocolors";
|
|
14
|
+
function parseRollbackArgs(args) {
|
|
15
|
+
const parsed = {};
|
|
16
|
+
for (let i = 0; i < args.length; i++) {
|
|
17
|
+
const arg = args[i];
|
|
18
|
+
switch (arg) {
|
|
19
|
+
case "--project": {
|
|
20
|
+
const value = args[++i];
|
|
21
|
+
if (value === void 0) throw new Error("irtio rollback: --project needs a value");
|
|
22
|
+
parsed.project = value;
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
case "--url": {
|
|
26
|
+
const value = args[++i];
|
|
27
|
+
if (value === void 0) throw new Error("irtio rollback: --url needs a value");
|
|
28
|
+
parsed.url = value;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
default: {
|
|
32
|
+
if (parsed.version !== void 0) {
|
|
33
|
+
throw new Error(`irtio rollback: unknown option ${JSON.stringify(arg)}`);
|
|
34
|
+
}
|
|
35
|
+
const version = Number(arg);
|
|
36
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
37
|
+
throw new Error("irtio rollback: <version> must be a positive integer");
|
|
38
|
+
}
|
|
39
|
+
parsed.version = version;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (parsed.version === void 0) {
|
|
44
|
+
throw new Error("irtio rollback: a version is required \u2014 irtio rollback <version>");
|
|
45
|
+
}
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
48
|
+
async function runRollback(options) {
|
|
49
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
50
|
+
const controlUrl = await resolveControlUrlForUser(options.controlUrl);
|
|
51
|
+
const client = options.client ?? await createApiClient(controlUrl);
|
|
52
|
+
log(
|
|
53
|
+
`${pc.yellow("rolling back")} to v${options.version} \u2014 rooms that migrated past it ${pc.yellow("discard")} everything written since their migration`
|
|
54
|
+
);
|
|
55
|
+
const result = await client.post(`/v1/projects/${options.project}/rollback`, {
|
|
56
|
+
version: options.version
|
|
57
|
+
});
|
|
58
|
+
log(
|
|
59
|
+
pc.green(
|
|
60
|
+
`v${result.rolledBack.map((v) => `${v}`).join(", v")} rolled back; v${result.version} serves again`
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
for (const room of result.rooms) {
|
|
64
|
+
if (room.action === "restored") {
|
|
65
|
+
log(pc.dim(` ${room.roomId}: restores its pre-migration state on its next start`));
|
|
66
|
+
} else {
|
|
67
|
+
log(pc.yellow(` ${room.roomId}: untouched \u2014 ${room.reason ?? "no pre-migration state"}`));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (result.rooms.length === 0) log(pc.dim(" no rooms on record for this project"));
|
|
71
|
+
if (result.applied === "stopped") {
|
|
72
|
+
log(pc.dim("the tenant was stopped; the next join brings rooms back on the older version"));
|
|
73
|
+
} else {
|
|
74
|
+
log(pc.dim("no tenant was running; the next placement serves the older version"));
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
async function readIrtioJsonProject(cwd) {
|
|
79
|
+
const file = path.join(cwd, "irtio.json");
|
|
80
|
+
if (!existsSync(file)) return void 0;
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
84
|
+
} catch {
|
|
85
|
+
throw new Error(`irtio rollback: ${file} is not valid JSON`);
|
|
86
|
+
}
|
|
87
|
+
return typeof parsed.project === "string" && parsed.project.length > 0 ? parsed.project : void 0;
|
|
88
|
+
}
|
|
89
|
+
async function rollback(args, deps = {}) {
|
|
90
|
+
const log = deps.log ?? ((line) => console.log(line));
|
|
91
|
+
const errorLog = deps.errorLog ?? ((line) => console.error(line));
|
|
92
|
+
try {
|
|
93
|
+
const parsed = parseRollbackArgs(args);
|
|
94
|
+
const project = parsed.project ?? await readIrtioJsonProject(process.cwd());
|
|
95
|
+
if (project === void 0) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"irtio rollback: no project id \u2014 pass --project or run this from a project with irtio.json"
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
await runRollback({
|
|
101
|
+
project,
|
|
102
|
+
version: parsed.version,
|
|
103
|
+
log,
|
|
104
|
+
...parsed.url !== void 0 ? { controlUrl: parsed.url } : {},
|
|
105
|
+
...deps.client !== void 0 ? { client: deps.client } : {}
|
|
106
|
+
});
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (isLoginRequired(err)) {
|
|
109
|
+
errorLog(pc.red("not logged in"));
|
|
110
|
+
errorLog("run: irtio login");
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
errorLog(pc.red(err instanceof Error ? err.message : String(err)));
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export {
|
|
119
|
+
parseRollbackArgs,
|
|
120
|
+
rollback,
|
|
121
|
+
runRollback
|
|
122
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createApiClient,
|
|
3
|
+
isLoginRequired
|
|
4
|
+
} from "./chunk-I37DLT7K.js";
|
|
5
|
+
import {
|
|
6
|
+
resolveControlUrlForUser
|
|
7
|
+
} from "./chunk-NVUKSP5U.js";
|
|
8
|
+
|
|
9
|
+
// src/rooms.ts
|
|
10
|
+
import { existsSync } from "fs";
|
|
11
|
+
import { readFile } from "fs/promises";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import pc from "picocolors";
|
|
14
|
+
function parseRoomsArgs(args) {
|
|
15
|
+
const parsed = { sub: "list" };
|
|
16
|
+
let rest = args;
|
|
17
|
+
const first = args[0];
|
|
18
|
+
if (first === "saves" || first === "restore") {
|
|
19
|
+
parsed.sub = first;
|
|
20
|
+
const room = args[1];
|
|
21
|
+
if (room === void 0 || room.startsWith("-")) {
|
|
22
|
+
throw new Error(`irtio rooms ${first}: needs a room id`);
|
|
23
|
+
}
|
|
24
|
+
parsed.room = room;
|
|
25
|
+
rest = args.slice(2);
|
|
26
|
+
}
|
|
27
|
+
for (let i = 0; i < rest.length; i++) {
|
|
28
|
+
const arg = rest[i];
|
|
29
|
+
switch (arg) {
|
|
30
|
+
case "--save": {
|
|
31
|
+
const value = rest[++i];
|
|
32
|
+
if (value === void 0) throw new Error("irtio rooms: --save needs a value");
|
|
33
|
+
parsed.save = value;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
case "--project": {
|
|
37
|
+
const value = rest[++i];
|
|
38
|
+
if (value === void 0) throw new Error("irtio rooms: --project needs a value");
|
|
39
|
+
parsed.project = value;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case "--url": {
|
|
43
|
+
const value = rest[++i];
|
|
44
|
+
if (value === void 0) throw new Error("irtio rooms: --url needs a value");
|
|
45
|
+
parsed.url = value;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
default:
|
|
49
|
+
throw new Error(`irtio rooms: unknown option ${JSON.stringify(arg)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (parsed.sub === "restore" && parsed.save === void 0) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"irtio rooms restore: --save <id> is required \u2014 run `irtio rooms saves <room>` to see them"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return parsed;
|
|
58
|
+
}
|
|
59
|
+
function formatBytes(bytes) {
|
|
60
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
61
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
62
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
63
|
+
}
|
|
64
|
+
function formatAge(iso, now) {
|
|
65
|
+
const ms = now - Date.parse(iso);
|
|
66
|
+
if (!Number.isFinite(ms) || ms < 0) return "\u2014";
|
|
67
|
+
const mins = Math.floor(ms / 6e4);
|
|
68
|
+
if (mins < 1) return "just now";
|
|
69
|
+
if (mins < 60) return `${mins}m ago`;
|
|
70
|
+
const hours = Math.floor(mins / 60);
|
|
71
|
+
if (hours < 24) return `${hours}h ago`;
|
|
72
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
73
|
+
}
|
|
74
|
+
function statusColor(status) {
|
|
75
|
+
return status === "active" || status === "running" ? pc.green(status) : status === "draining" ? pc.yellow(status) : pc.dim(status);
|
|
76
|
+
}
|
|
77
|
+
function formatTable(rows) {
|
|
78
|
+
if (rows.length === 0) return [pc.dim("no rooms")];
|
|
79
|
+
const idWidth = Math.max(4, ...rows.map((r) => r.roomId.length));
|
|
80
|
+
const out = [pc.dim(`${"ROOM".padEnd(idWidth)} STATUS LAST SEEN`)];
|
|
81
|
+
for (const r of rows) {
|
|
82
|
+
out.push(
|
|
83
|
+
`${r.roomId.padEnd(idWidth)} ${statusColor(r.status).padEnd(10)} ${pc.dim(r.lastSeen)}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
function formatSaves(rows, now) {
|
|
89
|
+
if (rows.length === 0) {
|
|
90
|
+
return [pc.dim("no saves"), pc.dim("a room writes one when its code calls room.save()")];
|
|
91
|
+
}
|
|
92
|
+
const idWidth = Math.max(7, ...rows.map((r) => r.saveId.length));
|
|
93
|
+
const out = [pc.dim(`${"SAVE ID".padEnd(idWidth)} AGE SIZE DEPLOY`)];
|
|
94
|
+
for (const r of rows) {
|
|
95
|
+
out.push(
|
|
96
|
+
`${r.saveId.padEnd(idWidth)} ${formatAge(r.createdAt, now).padEnd(10)} ${formatBytes(r.bytes).padEnd(8)} ${pc.dim(`v${r.version}`)}`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
async function runSaves(options) {
|
|
102
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
103
|
+
const controlUrl = await resolveControlUrlForUser(options.controlUrl);
|
|
104
|
+
const client = options.client ?? await createApiClient(controlUrl);
|
|
105
|
+
const rows = await client.get(
|
|
106
|
+
`/v1/projects/${options.project}/rooms/${encodeURIComponent(options.room)}/saves`
|
|
107
|
+
);
|
|
108
|
+
for (const line of formatSaves(rows, options.now ?? Date.now())) log(line);
|
|
109
|
+
return rows;
|
|
110
|
+
}
|
|
111
|
+
async function runRestore(options) {
|
|
112
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
113
|
+
const controlUrl = await resolveControlUrlForUser(options.controlUrl);
|
|
114
|
+
const client = options.client ?? await createApiClient(controlUrl);
|
|
115
|
+
log(
|
|
116
|
+
`${pc.yellow("discarding")} the current state of room ${pc.bold(options.room)} and restoring save ${pc.bold(options.saveId)}`
|
|
117
|
+
);
|
|
118
|
+
const result = await client.post(
|
|
119
|
+
`/v1/projects/${options.project}/rooms/${encodeURIComponent(options.room)}/restore`,
|
|
120
|
+
{ saveId: options.saveId }
|
|
121
|
+
);
|
|
122
|
+
if (result.applied === "stopped") {
|
|
123
|
+
log(pc.green(`restored: ${options.room} was live, so its tenant was stopped`));
|
|
124
|
+
log(
|
|
125
|
+
pc.dim(
|
|
126
|
+
"the room comes back from the save on the next join; every other room in this project was stopped too and comes back from its own snapshot, unchanged"
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
} else {
|
|
130
|
+
log(pc.green(`queued: ${options.room} was not running`));
|
|
131
|
+
log(pc.dim("the restore is applied at the next placement \u2014 the next join starts it"));
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
async function runRooms(options) {
|
|
136
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
137
|
+
const controlUrl = await resolveControlUrlForUser(options.controlUrl);
|
|
138
|
+
const client = options.client ?? await createApiClient(controlUrl);
|
|
139
|
+
const rows = await client.get(`/v1/projects/${options.project}/rooms`);
|
|
140
|
+
for (const line of formatTable(rows)) log(line);
|
|
141
|
+
return rows;
|
|
142
|
+
}
|
|
143
|
+
async function readIrtioJsonProject(cwd) {
|
|
144
|
+
const file = path.join(cwd, "irtio.json");
|
|
145
|
+
if (!existsSync(file)) return void 0;
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
149
|
+
} catch {
|
|
150
|
+
throw new Error(`irtio rooms: ${file} is not valid JSON`);
|
|
151
|
+
}
|
|
152
|
+
return typeof parsed.project === "string" && parsed.project.length > 0 ? parsed.project : void 0;
|
|
153
|
+
}
|
|
154
|
+
async function rooms(args, deps = {}) {
|
|
155
|
+
const log = deps.log ?? ((line) => console.log(line));
|
|
156
|
+
const errorLog = deps.errorLog ?? ((line) => console.error(line));
|
|
157
|
+
try {
|
|
158
|
+
const parsed = parseRoomsArgs(args);
|
|
159
|
+
const project = parsed.project ?? await readIrtioJsonProject(process.cwd());
|
|
160
|
+
if (project === void 0) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
"irtio rooms: no project id \u2014 pass --project or run this from a project with irtio.json"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const common = {
|
|
166
|
+
project,
|
|
167
|
+
log,
|
|
168
|
+
...parsed.url !== void 0 ? { controlUrl: parsed.url } : {},
|
|
169
|
+
...deps.client !== void 0 ? { client: deps.client } : {}
|
|
170
|
+
};
|
|
171
|
+
if (parsed.sub === "saves") {
|
|
172
|
+
await runSaves({ ...common, room: parsed.room });
|
|
173
|
+
} else if (parsed.sub === "restore") {
|
|
174
|
+
await runRestore({
|
|
175
|
+
...common,
|
|
176
|
+
room: parsed.room,
|
|
177
|
+
saveId: parsed.save
|
|
178
|
+
});
|
|
179
|
+
} else {
|
|
180
|
+
await runRooms(common);
|
|
181
|
+
}
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (isLoginRequired(err)) {
|
|
184
|
+
errorLog(pc.red("not logged in"));
|
|
185
|
+
errorLog("run: irtio login");
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
errorLog(pc.red(err instanceof Error ? err.message : String(err)));
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export {
|
|
194
|
+
parseRoomsArgs,
|
|
195
|
+
rooms,
|
|
196
|
+
runRestore,
|
|
197
|
+
runRooms,
|
|
198
|
+
runSaves
|
|
199
|
+
};
|
package/dist/simulate.d.ts
CHANGED
|
@@ -23,6 +23,9 @@ interface SimulateArgs {
|
|
|
23
23
|
url?: string;
|
|
24
24
|
key?: string;
|
|
25
25
|
trace?: string;
|
|
26
|
+
mispredictionMax?: number;
|
|
27
|
+
snapsMax?: number;
|
|
28
|
+
correctionsMax?: number;
|
|
26
29
|
}
|
|
27
30
|
/** Hand-rolled, like `parseDevArgs`: the CLI has no argument-parsing dependency. */
|
|
28
31
|
declare function parseSimulateArgs(args: readonly string[]): SimulateArgs;
|
|
@@ -40,10 +43,43 @@ declare function loadProjectSchema(options: LoadSchemaOptions): Promise<{
|
|
|
40
43
|
schema: AnySchema;
|
|
41
44
|
file: string;
|
|
42
45
|
} | undefined>;
|
|
46
|
+
/** The shared world-builder's exports, as `joinRoom({ physics })` wants them. */
|
|
47
|
+
interface LoadedWorld {
|
|
48
|
+
readonly gravity: {
|
|
49
|
+
x: number;
|
|
50
|
+
y: number;
|
|
51
|
+
z: number;
|
|
52
|
+
};
|
|
53
|
+
readonly timestep?: number | undefined;
|
|
54
|
+
readonly setup?: ((world: unknown, rapier: unknown) => void) | undefined;
|
|
55
|
+
readonly bodies?: Record<string, (...args: never[]) => unknown> | undefined;
|
|
56
|
+
readonly intents?: Record<string, (...args: never[]) => void> | undefined;
|
|
57
|
+
readonly file: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Bundles and imports the project's shared world-builder module, so the bots predict physics the
|
|
61
|
+
* way a browser client would. The engine stays external — it must be the project's own copy,
|
|
62
|
+
* resolved at import time, exactly as a room bundle resolves it.
|
|
63
|
+
*/
|
|
64
|
+
declare function loadProjectWorld(options: LoadSchemaOptions): Promise<LoadedWorld | undefined>;
|
|
65
|
+
/**
|
|
66
|
+
* The build-twice determinism check: builds two worlds from the shared `setup` and compares
|
|
67
|
+
* Rapier's own snapshots byte for byte. A builder that is not pure over synced inputs
|
|
68
|
+
* (`Math.random()`, a clock, module state) produces different worlds on the two sides, and every
|
|
69
|
+
* "misprediction" it causes would be blamed on netcode — catch it here instead. Returns an error
|
|
70
|
+
* description, or `undefined` when the worlds agree.
|
|
71
|
+
*/
|
|
72
|
+
declare function checkWorldDeterminism(world: LoadedWorld): Promise<string | undefined>;
|
|
43
73
|
interface RunSimulationOptions extends Partial<SimulateArgs> {
|
|
44
74
|
/** Project root; defaults to `process.cwd()`. */
|
|
45
75
|
readonly cwd?: string;
|
|
46
76
|
readonly log?: (line: string) => void;
|
|
77
|
+
/**
|
|
78
|
+
* Correction-storm threshold override (per bot per second; `@irtio/bots` default 5). A
|
|
79
|
+
* physics room judges one correction opportunity per tick, so contact-heavy moments on slow
|
|
80
|
+
* hardware can burst past the cursor-tuned default without anything being wrong.
|
|
81
|
+
*/
|
|
82
|
+
readonly correctionsPerSecMax?: number;
|
|
47
83
|
/** @internal Test seam, same as `startDev`'s. */
|
|
48
84
|
readonly irtioPackages?: Record<string, string> | undefined;
|
|
49
85
|
}
|
|
@@ -52,6 +88,11 @@ interface SimulationRun {
|
|
|
52
88
|
readonly tracePath: string;
|
|
53
89
|
/** `false` when there was no schema module and the run fell back to a relay simulation. */
|
|
54
90
|
readonly schema: boolean;
|
|
91
|
+
/** `true` when the bots predicted physics from a shared world-builder module (D22 part 2). */
|
|
92
|
+
readonly predicted: boolean;
|
|
93
|
+
/** Result of the build-twice world-builder check: the failure text, or `undefined` if it held
|
|
94
|
+
* (or did not apply). A failure also fails the run. */
|
|
95
|
+
readonly worldError?: string;
|
|
55
96
|
/** Everything that was printed, in order — the same lines `log` received. */
|
|
56
97
|
readonly output: readonly string[];
|
|
57
98
|
}
|
|
@@ -59,4 +100,4 @@ interface SimulationRun {
|
|
|
59
100
|
declare function runSimulation(options?: RunSimulationOptions): Promise<SimulationRun>;
|
|
60
101
|
declare function simulate(args: readonly string[]): Promise<void>;
|
|
61
102
|
|
|
62
|
-
export { type LoadSchemaOptions, type RunSimulationOptions, type SimulateArgs, type SimulationRun, loadProjectSchema, parseSimulateArgs, runSimulation, simulate };
|
|
103
|
+
export { type LoadSchemaOptions, type RunSimulationOptions, type SimulateArgs, type SimulationRun, checkWorldDeterminism, loadProjectSchema, loadProjectWorld, parseSimulateArgs, runSimulation, simulate };
|
package/dist/simulate.js
CHANGED
|
@@ -10,6 +10,7 @@ var DEFAULT_URL = "ws://localhost:7070";
|
|
|
10
10
|
var DEFAULT_BOTS = 5;
|
|
11
11
|
var DEFAULT_SECONDS = 10;
|
|
12
12
|
var SCHEMA_CANDIDATES = ["irtio/schema.ts", "irtio/schema.js", "schema.ts"];
|
|
13
|
+
var WORLD_CANDIDATES = ["irtio/world.ts", "irtio/world.js", "world.ts"];
|
|
13
14
|
function positive(raw, flag) {
|
|
14
15
|
const value = Number(raw);
|
|
15
16
|
if (!Number.isFinite(value) || value <= 0) {
|
|
@@ -17,6 +18,13 @@ function positive(raw, flag) {
|
|
|
17
18
|
}
|
|
18
19
|
return value;
|
|
19
20
|
}
|
|
21
|
+
function nonNegativeInt(raw, flag) {
|
|
22
|
+
const value = Number(raw);
|
|
23
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
24
|
+
throw new Error(`irtio simulate: ${flag} must be a non-negative integer`);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
20
28
|
function parseSimulateArgs(args) {
|
|
21
29
|
const parsed = { bots: DEFAULT_BOTS, seconds: DEFAULT_SECONDS, cheat: false };
|
|
22
30
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -55,6 +63,15 @@ function parseSimulateArgs(args) {
|
|
|
55
63
|
case "--cheat":
|
|
56
64
|
parsed.cheat = true;
|
|
57
65
|
break;
|
|
66
|
+
case "--misprediction-max":
|
|
67
|
+
parsed.mispredictionMax = positive(value(), "--misprediction-max");
|
|
68
|
+
break;
|
|
69
|
+
case "--snaps-max":
|
|
70
|
+
parsed.snapsMax = nonNegativeInt(value(), "--snaps-max");
|
|
71
|
+
break;
|
|
72
|
+
case "--corrections-max":
|
|
73
|
+
parsed.correctionsMax = positive(value(), "--corrections-max");
|
|
74
|
+
break;
|
|
58
75
|
default:
|
|
59
76
|
throw new Error(`irtio simulate: unknown option ${JSON.stringify(arg)}`);
|
|
60
77
|
}
|
|
@@ -71,6 +88,7 @@ function monorepoPackages() {
|
|
|
71
88
|
"@irtio/server": path.join(packagesDir, "server/src/index.ts")
|
|
72
89
|
};
|
|
73
90
|
}
|
|
91
|
+
var importSeq = 0;
|
|
74
92
|
function looksLikeSchema(value) {
|
|
75
93
|
const candidate = value;
|
|
76
94
|
return typeof candidate === "object" && candidate !== null && Array.isArray(candidate.collections) && candidate.hash8 instanceof Uint8Array;
|
|
@@ -91,7 +109,7 @@ async function loadProjectSchema(options) {
|
|
|
91
109
|
outfile,
|
|
92
110
|
...alias !== void 0 ? { alias } : {}
|
|
93
111
|
});
|
|
94
|
-
const module = await import(`${pathToFileURL(outfile).href}?v=${Date.now()}`);
|
|
112
|
+
const module = await import(`${pathToFileURL(outfile).href}?v=${Date.now()}-${importSeq++}`);
|
|
95
113
|
const schema = module.schema ?? module.default;
|
|
96
114
|
if (!looksLikeSchema(schema)) {
|
|
97
115
|
const names = Object.keys(module).join(", ") || "nothing";
|
|
@@ -103,6 +121,63 @@ async function loadProjectSchema(options) {
|
|
|
103
121
|
}
|
|
104
122
|
return { schema, file: entry };
|
|
105
123
|
}
|
|
124
|
+
async function loadProjectWorld(options) {
|
|
125
|
+
const entry = WORLD_CANDIDATES.map((c) => path.resolve(options.cwd, c)).find(
|
|
126
|
+
(f) => existsSync(f)
|
|
127
|
+
);
|
|
128
|
+
if (entry === void 0) return void 0;
|
|
129
|
+
await mkdir(options.outDir, { recursive: true });
|
|
130
|
+
const outfile = path.join(options.outDir, "world.mjs");
|
|
131
|
+
const alias = options.irtioPackages ?? monorepoPackages();
|
|
132
|
+
await esbuild.build({
|
|
133
|
+
entryPoints: [entry],
|
|
134
|
+
bundle: true,
|
|
135
|
+
format: "esm",
|
|
136
|
+
platform: "node",
|
|
137
|
+
outfile,
|
|
138
|
+
external: ["@dimforge/rapier3d-compat"],
|
|
139
|
+
...alias !== void 0 ? { alias } : {}
|
|
140
|
+
});
|
|
141
|
+
const module = await import(`${pathToFileURL(outfile).href}?v=${Date.now()}-${importSeq++}`);
|
|
142
|
+
const gravity = module.gravity;
|
|
143
|
+
if (typeof gravity !== "object" || gravity === null || typeof gravity.x !== "number" || typeof gravity.y !== "number" || typeof gravity.z !== "number") {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`irtio simulate: ${entry} does not export a { x, y, z } gravity.
|
|
146
|
+
the shared world-builder must export the same gravity the room config uses
|
|
147
|
+
(this is what \`irtio init --physics\` writes)`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
gravity,
|
|
152
|
+
...typeof module.timestep === "number" ? { timestep: module.timestep } : {},
|
|
153
|
+
...typeof module.setup === "function" ? { setup: module.setup } : {},
|
|
154
|
+
...typeof module.bodies === "object" && module.bodies !== null ? { bodies: module.bodies } : {},
|
|
155
|
+
...typeof module.intents === "object" && module.intents !== null ? { intents: module.intents } : {},
|
|
156
|
+
file: entry
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
async function checkWorldDeterminism(world) {
|
|
160
|
+
if (!world.setup) return void 0;
|
|
161
|
+
const { initPhysics } = await import("@irtio/runtime");
|
|
162
|
+
const rapier = await initPhysics();
|
|
163
|
+
const build2 = () => {
|
|
164
|
+
const w = new rapier.World({ ...world.gravity });
|
|
165
|
+
try {
|
|
166
|
+
world.setup(w, rapier);
|
|
167
|
+
return w.takeSnapshot();
|
|
168
|
+
} finally {
|
|
169
|
+
w.free();
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const first = build2();
|
|
173
|
+
const second = build2();
|
|
174
|
+
if (first.length === second.length && everyByteEqual(first, second)) return void 0;
|
|
175
|
+
return `the world builder is not deterministic: two builds of setup() from ${world.file} disagree. It must be pure over synced inputs \u2014 no Math.random(), no clock, no module state; put seeds and level parameters in synced state instead.`;
|
|
176
|
+
}
|
|
177
|
+
function everyByteEqual(a, b) {
|
|
178
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
106
181
|
function rate(bytesPerSecond) {
|
|
107
182
|
return bytesPerSecond >= 1e3 ? `${(bytesPerSecond / 1e3).toFixed(1)} kB/s` : `${Math.round(bytesPerSecond)} B/s`;
|
|
108
183
|
}
|
|
@@ -114,9 +189,11 @@ function formatReport(report, schemaLoaded, cheating) {
|
|
|
114
189
|
}
|
|
115
190
|
lines.push("");
|
|
116
191
|
const convergence = report.convergenceLagMs === void 0 ? "convergence not sampled" : `convergence ${report.convergenceLagMs} ms (p50 of ${report.convergence?.samples ?? 0})`;
|
|
192
|
+
const { mispredictions, mispredictionMagnitude, mispredictionMax, snaps } = report.totals;
|
|
193
|
+
const misprediction = mispredictions === 0 ? "misprediction 0" : `misprediction mean ${(mispredictionMagnitude / mispredictions).toFixed(1)} max ${mispredictionMax.toFixed(1)}`;
|
|
117
194
|
lines.push(
|
|
118
195
|
pc.dim(
|
|
119
|
-
` ${report.framesPerSecond} frames/s \xB7 in ${rate(report.bytesInPerSecondPerBot)}/bot \xB7 out ${rate(report.bytesOutPerSecondPerBot)}/bot \xB7 ${report.totals.corrections} corrections \xB7 ${convergence}`
|
|
196
|
+
` ${report.framesPerSecond} frames/s \xB7 in ${rate(report.bytesInPerSecondPerBot)}/bot \xB7 out ${rate(report.bytesOutPerSecondPerBot)}/bot \xB7 ${report.totals.corrections} corrections \xB7 ${misprediction} \xB7 ${snaps} snap(s) \xB7 ${convergence}`
|
|
120
197
|
)
|
|
121
198
|
);
|
|
122
199
|
if (report.tracePath !== void 0) lines.push(pc.dim(` trace: ${report.tracePath}`));
|
|
@@ -161,16 +238,37 @@ async function runSimulation(options = {}) {
|
|
|
161
238
|
outDir,
|
|
162
239
|
irtioPackages: options.irtioPackages
|
|
163
240
|
});
|
|
241
|
+
const hasPhysics = loaded !== void 0 && loaded.schema.collections.some(
|
|
242
|
+
(c) => c.physics !== void 0
|
|
243
|
+
);
|
|
244
|
+
const world = hasPhysics ? await loadProjectWorld({ cwd, outDir, irtioPackages: options.irtioPackages }) : void 0;
|
|
245
|
+
let worldError;
|
|
246
|
+
if (world) {
|
|
247
|
+
worldError = await checkWorldDeterminism(world);
|
|
248
|
+
}
|
|
164
249
|
const common = {
|
|
165
250
|
url,
|
|
166
251
|
durationMs: seconds * 1e3,
|
|
167
252
|
...options.key !== void 0 ? { key: options.key } : {},
|
|
168
|
-
...options.room !== void 0 ? { room: options.room } : {}
|
|
253
|
+
...options.room !== void 0 ? { room: options.room } : {},
|
|
254
|
+
...options.correctionsPerSecMax !== void 0 ? { correctionsPerSecMax: options.correctionsPerSecMax } : options.correctionsMax !== void 0 ? { correctionsPerSecMax: options.correctionsMax } : {},
|
|
255
|
+
...options.mispredictionMax !== void 0 ? { mispredictionMagnitudeMax: options.mispredictionMax } : {},
|
|
256
|
+
...options.snapsMax !== void 0 ? { snapsMax: options.snapsMax } : {}
|
|
169
257
|
};
|
|
258
|
+
const predicted = world !== void 0 && worldError === void 0;
|
|
170
259
|
const runner = loaded ? await spawnBots(bots, {
|
|
171
260
|
...common,
|
|
172
261
|
schema: loaded.schema,
|
|
173
|
-
script: randomScript(loaded.schema, { cheat: options.cheat ?? false })
|
|
262
|
+
script: randomScript(loaded.schema, { cheat: options.cheat ?? false }),
|
|
263
|
+
...predicted ? {
|
|
264
|
+
physics: {
|
|
265
|
+
gravity: world.gravity,
|
|
266
|
+
...world.timestep !== void 0 ? { timestep: world.timestep } : {},
|
|
267
|
+
...world.setup !== void 0 ? { setup: world.setup } : {},
|
|
268
|
+
...world.bodies !== void 0 ? { bodies: world.bodies } : {},
|
|
269
|
+
...world.intents !== void 0 ? { intents: world.intents } : {}
|
|
270
|
+
}
|
|
271
|
+
} : {}
|
|
174
272
|
}) : await spawnBots(bots, { ...common, script: relayEchoScript() });
|
|
175
273
|
log("");
|
|
176
274
|
log(
|
|
@@ -190,10 +288,33 @@ async function runSimulation(options = {}) {
|
|
|
190
288
|
for (const line of formatReport(report, loaded !== void 0, options.cheat ?? false)) {
|
|
191
289
|
log(line);
|
|
192
290
|
}
|
|
291
|
+
if (worldError !== void 0) {
|
|
292
|
+
log(pc.red(` FAIL world-builder: ${worldError}`));
|
|
293
|
+
} else if (predicted) {
|
|
294
|
+
const suppressed = report.totals.suppressedCorrections;
|
|
295
|
+
log(
|
|
296
|
+
pc.dim(
|
|
297
|
+
` bots predicted physics from ${path.relative(cwd, world?.file ?? "")} (build-twice check held; ${suppressed} within-epsilon correction(s) suppressed)`
|
|
298
|
+
)
|
|
299
|
+
);
|
|
300
|
+
} else if (hasPhysics) {
|
|
301
|
+
log(
|
|
302
|
+
pc.yellow(
|
|
303
|
+
" this schema has physics but no irtio/world.ts \u2014 bots interpolated instead of predicting, so body-field corrections count as body-sync, not misprediction.\n export { gravity, setup, bodies, intents } from irtio/world.ts to simulate prediction (what `irtio init --physics` writes)."
|
|
304
|
+
)
|
|
305
|
+
);
|
|
306
|
+
}
|
|
193
307
|
for (const failure of runner.scriptErrors) {
|
|
194
308
|
log(pc.red(` bot ${failure.bot} script failed: ${String(failure.error)}`));
|
|
195
309
|
}
|
|
196
|
-
return {
|
|
310
|
+
return {
|
|
311
|
+
report,
|
|
312
|
+
tracePath,
|
|
313
|
+
schema: loaded !== void 0,
|
|
314
|
+
predicted,
|
|
315
|
+
...worldError !== void 0 ? { worldError } : {},
|
|
316
|
+
output
|
|
317
|
+
};
|
|
197
318
|
}
|
|
198
319
|
async function simulate(args) {
|
|
199
320
|
let parsed;
|
|
@@ -206,7 +327,7 @@ async function simulate(args) {
|
|
|
206
327
|
}
|
|
207
328
|
try {
|
|
208
329
|
const run = await runSimulation(parsed);
|
|
209
|
-
if (!run.report.ok) process.exitCode = 1;
|
|
330
|
+
if (!run.report.ok || run.worldError !== void 0) process.exitCode = 1;
|
|
210
331
|
} catch (err) {
|
|
211
332
|
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
212
333
|
console.error(pc.dim("is `irtio dev` running? pass --url to point somewhere else."));
|
|
@@ -214,7 +335,9 @@ async function simulate(args) {
|
|
|
214
335
|
}
|
|
215
336
|
}
|
|
216
337
|
export {
|
|
338
|
+
checkWorldDeterminism,
|
|
217
339
|
loadProjectSchema,
|
|
340
|
+
loadProjectWorld,
|
|
218
341
|
parseSimulateArgs,
|
|
219
342
|
runSimulation,
|
|
220
343
|
simulate
|