@irtio/cli 0.8.0 → 0.10.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/{chunk-BQBOBFBO.js → chunk-VZDBLJQ2.js} +1 -1
- package/dist/{chunk-WFMRNGO5.js → chunk-YJB5GQHT.js} +39 -0
- package/dist/deploy.js +2 -2
- package/dist/{dev-VJ2ATJTF.js → dev-6WP6FP5N.js} +372 -68
- package/dist/index.js +2 -2
- package/dist/init.js +12 -6
- package/dist/{static-deploy-7UCYINJB.js → static-deploy-OHDBVLA7.js} +2 -2
- package/package.json +7 -7
|
@@ -19,6 +19,44 @@ function staticPathProblem(path2) {
|
|
|
19
19
|
}
|
|
20
20
|
return void 0;
|
|
21
21
|
}
|
|
22
|
+
function staticContentType(path2) {
|
|
23
|
+
const dot = path2.lastIndexOf(".");
|
|
24
|
+
const ext = dot === -1 ? "" : path2.slice(dot + 1).toLowerCase();
|
|
25
|
+
return STATIC_CONTENT_TYPES[ext] ?? "application/octet-stream";
|
|
26
|
+
}
|
|
27
|
+
var STATIC_CONTENT_TYPES = {
|
|
28
|
+
html: "text/html; charset=utf-8",
|
|
29
|
+
htm: "text/html; charset=utf-8",
|
|
30
|
+
css: "text/css; charset=utf-8",
|
|
31
|
+
js: "text/javascript; charset=utf-8",
|
|
32
|
+
mjs: "text/javascript; charset=utf-8",
|
|
33
|
+
map: "application/json; charset=utf-8",
|
|
34
|
+
json: "application/json; charset=utf-8",
|
|
35
|
+
txt: "text/plain; charset=utf-8",
|
|
36
|
+
md: "text/plain; charset=utf-8",
|
|
37
|
+
xml: "application/xml; charset=utf-8",
|
|
38
|
+
svg: "image/svg+xml",
|
|
39
|
+
png: "image/png",
|
|
40
|
+
jpg: "image/jpeg",
|
|
41
|
+
jpeg: "image/jpeg",
|
|
42
|
+
gif: "image/gif",
|
|
43
|
+
webp: "image/webp",
|
|
44
|
+
avif: "image/avif",
|
|
45
|
+
ico: "image/x-icon",
|
|
46
|
+
wasm: "application/wasm",
|
|
47
|
+
woff: "font/woff",
|
|
48
|
+
woff2: "font/woff2",
|
|
49
|
+
ttf: "font/ttf",
|
|
50
|
+
otf: "font/otf",
|
|
51
|
+
mp3: "audio/mpeg",
|
|
52
|
+
ogg: "audio/ogg",
|
|
53
|
+
wav: "audio/wav",
|
|
54
|
+
mp4: "video/mp4",
|
|
55
|
+
webm: "video/webm",
|
|
56
|
+
glb: "model/gltf-binary",
|
|
57
|
+
gltf: "model/gltf+json",
|
|
58
|
+
pdf: "application/pdf"
|
|
59
|
+
};
|
|
22
60
|
|
|
23
61
|
// ../store/src/disk.ts
|
|
24
62
|
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "fs/promises";
|
|
@@ -477,5 +515,6 @@ export {
|
|
|
477
515
|
pruneSaves,
|
|
478
516
|
STATIC_LIMITS,
|
|
479
517
|
staticPathProblem,
|
|
518
|
+
staticContentType,
|
|
480
519
|
DiskStore
|
|
481
520
|
};
|
package/dist/deploy.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
logEnsured,
|
|
3
3
|
runStaticDeploy
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-VZDBLJQ2.js";
|
|
5
5
|
import {
|
|
6
6
|
clientImportsRoom
|
|
7
7
|
} from "./chunk-TQU6345E.js";
|
|
8
|
-
import "./chunk-
|
|
8
|
+
import "./chunk-YJB5GQHT.js";
|
|
9
9
|
import {
|
|
10
10
|
ensureProject,
|
|
11
11
|
readProjectConfig
|
|
@@ -22,8 +22,9 @@ import {
|
|
|
22
22
|
ratingResultsProblem,
|
|
23
23
|
ratingValueProblem,
|
|
24
24
|
saveKey,
|
|
25
|
+
staticContentType,
|
|
25
26
|
verifyAssertion
|
|
26
|
-
} from "./chunk-
|
|
27
|
+
} from "./chunk-YJB5GQHT.js";
|
|
27
28
|
import {
|
|
28
29
|
readProjectConfig
|
|
29
30
|
} from "./chunk-JL235KIE.js";
|
|
@@ -42,9 +43,9 @@ import "./chunk-UPHQM6NZ.js";
|
|
|
42
43
|
// src/dev.ts
|
|
43
44
|
import { existsSync } from "fs";
|
|
44
45
|
import { watch } from "fs";
|
|
45
|
-
import { mkdir } from "fs/promises";
|
|
46
|
+
import { mkdir, rm, writeFile as writeFile2 } from "fs/promises";
|
|
46
47
|
import { createRequire } from "module";
|
|
47
|
-
import * as
|
|
48
|
+
import * as path2 from "path";
|
|
48
49
|
import { fileURLToPath, pathToFileURL as pathToFileURL3 } from "url";
|
|
49
50
|
|
|
50
51
|
// ../supervisor/src/contract.ts
|
|
@@ -126,8 +127,8 @@ function delayWithJitter(ms) {
|
|
|
126
127
|
return Math.max(0, ms + jitter);
|
|
127
128
|
}
|
|
128
129
|
function sleep(ms) {
|
|
129
|
-
return new Promise((
|
|
130
|
-
const t = setTimeout(
|
|
130
|
+
return new Promise((resolve3) => {
|
|
131
|
+
const t = setTimeout(resolve3, ms);
|
|
131
132
|
t.unref?.();
|
|
132
133
|
});
|
|
133
134
|
}
|
|
@@ -168,6 +169,7 @@ import {
|
|
|
168
169
|
BUS_MAILBOX_PREFIX as BUS_MAILBOX_PREFIX2,
|
|
169
170
|
BUS_OUTBOX as BUS_OUTBOX2,
|
|
170
171
|
CLOSE_EGRESS_WALL,
|
|
172
|
+
CLOSE_IDLE_TIMEOUT,
|
|
171
173
|
DEFAULT_ROOM_TYPE as DEFAULT_ROOM_TYPE3,
|
|
172
174
|
ErrorCode as ErrorCode3,
|
|
173
175
|
FrameType as FrameType3,
|
|
@@ -2329,9 +2331,9 @@ var WorkerHost = class {
|
|
|
2329
2331
|
}
|
|
2330
2332
|
onMessage(msg) {
|
|
2331
2333
|
if ((msg.t === "serialized" || msg.t === "inspected" || msg.t === "stats" || msg.t === "recording" || msg.t === "timeline") && msg.reqId !== void 0 && this.pending.has(msg.reqId)) {
|
|
2332
|
-
const
|
|
2334
|
+
const resolve3 = this.pending.get(msg.reqId);
|
|
2333
2335
|
this.pending.delete(msg.reqId);
|
|
2334
|
-
|
|
2336
|
+
resolve3?.(msg);
|
|
2335
2337
|
return;
|
|
2336
2338
|
}
|
|
2337
2339
|
if (msg.t === "crashed") {
|
|
@@ -2345,8 +2347,8 @@ var WorkerHost = class {
|
|
|
2345
2347
|
if (this.dead) return;
|
|
2346
2348
|
this.dead = true;
|
|
2347
2349
|
clearInterval(this.eluTimer);
|
|
2348
|
-
for (const [,
|
|
2349
|
-
|
|
2350
|
+
for (const [, resolve3] of this.pending) {
|
|
2351
|
+
resolve3({ t: "initFailed", reason });
|
|
2350
2352
|
}
|
|
2351
2353
|
this.pending.clear();
|
|
2352
2354
|
if (this.disposed) return;
|
|
@@ -2366,15 +2368,15 @@ var WorkerHost = class {
|
|
|
2366
2368
|
request(make, timeoutMs) {
|
|
2367
2369
|
if (this.dead || this.disposed) return Promise.resolve(void 0);
|
|
2368
2370
|
const reqId = ++this.reqId;
|
|
2369
|
-
return new Promise((
|
|
2371
|
+
return new Promise((resolve3) => {
|
|
2370
2372
|
const timer = setTimeout(() => {
|
|
2371
2373
|
this.pending.delete(reqId);
|
|
2372
|
-
|
|
2374
|
+
resolve3(void 0);
|
|
2373
2375
|
}, timeoutMs);
|
|
2374
2376
|
timer.unref?.();
|
|
2375
2377
|
this.pending.set(reqId, (msg) => {
|
|
2376
2378
|
clearTimeout(timer);
|
|
2377
|
-
|
|
2379
|
+
resolve3(msg);
|
|
2378
2380
|
});
|
|
2379
2381
|
this.post(make(reqId));
|
|
2380
2382
|
});
|
|
@@ -2507,10 +2509,10 @@ function codeOf(err) {
|
|
|
2507
2509
|
}
|
|
2508
2510
|
function defaultSetSystemTime(epochMs) {
|
|
2509
2511
|
if (process.platform !== "linux") return Promise.resolve(false);
|
|
2510
|
-
return new Promise((
|
|
2512
|
+
return new Promise((resolve3, reject) => {
|
|
2511
2513
|
execFile("date", ["-u", "-s", `@${Math.floor(epochMs / 1e3)}`], (err) => {
|
|
2512
2514
|
if (err) reject(err);
|
|
2513
|
-
else
|
|
2515
|
+
else resolve3(true);
|
|
2514
2516
|
});
|
|
2515
2517
|
});
|
|
2516
2518
|
}
|
|
@@ -2777,12 +2779,12 @@ var SupervisorImpl = class {
|
|
|
2777
2779
|
this.schemaHash = this.bundle?.schemaHash;
|
|
2778
2780
|
this.checkDeclaredSizing();
|
|
2779
2781
|
this.workerEntry = this.config.workerEntry ?? resolveWorkerEntry();
|
|
2780
|
-
await new Promise((
|
|
2782
|
+
await new Promise((resolve3, reject) => {
|
|
2781
2783
|
this.http.once("error", reject);
|
|
2782
2784
|
this.http.listen(this.config.port, this.config.host ?? "127.0.0.1", () => {
|
|
2783
2785
|
const address = this.http.address();
|
|
2784
2786
|
this.port = typeof address === "object" && address !== null ? address.port : this.config.port;
|
|
2785
|
-
|
|
2787
|
+
resolve3();
|
|
2786
2788
|
});
|
|
2787
2789
|
});
|
|
2788
2790
|
this.pingTimer = setInterval(() => this.keepalive(), WS_PING_MS);
|
|
@@ -2898,8 +2900,8 @@ var SupervisorImpl = class {
|
|
|
2898
2900
|
*/
|
|
2899
2901
|
async assignedRooms(candidates) {
|
|
2900
2902
|
const shard = this.config.shard ?? 0;
|
|
2901
|
-
const sleep2 = this.config.shardRetrySleep ?? ((ms) => new Promise((
|
|
2902
|
-
const t = setTimeout(
|
|
2903
|
+
const sleep2 = this.config.shardRetrySleep ?? ((ms) => new Promise((resolve3) => {
|
|
2904
|
+
const t = setTimeout(resolve3, ms);
|
|
2903
2905
|
t.unref?.();
|
|
2904
2906
|
}));
|
|
2905
2907
|
let delayMs = SHARD_ASSIGNMENT_RETRY_MS[0];
|
|
@@ -3038,8 +3040,8 @@ var SupervisorImpl = class {
|
|
|
3038
3040
|
await room.worker?.terminate();
|
|
3039
3041
|
room.worker = void 0;
|
|
3040
3042
|
}
|
|
3041
|
-
await new Promise((
|
|
3042
|
-
await new Promise((
|
|
3043
|
+
await new Promise((resolve3) => this.wss.close(() => resolve3()));
|
|
3044
|
+
await new Promise((resolve3) => this.http.close(() => resolve3()));
|
|
3043
3045
|
}
|
|
3044
3046
|
// -------------------------------------------------------------------------
|
|
3045
3047
|
// Logging
|
|
@@ -3600,7 +3602,7 @@ var SupervisorImpl = class {
|
|
|
3600
3602
|
await loadBundle(ref.bundlePath, ref.version, DEFAULT_ROOM_TYPE3)
|
|
3601
3603
|
);
|
|
3602
3604
|
}
|
|
3603
|
-
for (const [type,
|
|
3605
|
+
for (const [type, path3] of Object.entries(ref.rooms ?? {})) {
|
|
3604
3606
|
if (!ROOM_TYPE_RE.test(type)) {
|
|
3605
3607
|
throw new Error(
|
|
3606
3608
|
`irtio: deployment v${ref.version} names a room type ${JSON.stringify(type)}, which is not a legal type name (lowercase, starting with a letter or digit, up to 24 characters)`
|
|
@@ -3609,7 +3611,7 @@ var SupervisorImpl = class {
|
|
|
3609
3611
|
if (bundles.has(type)) {
|
|
3610
3612
|
throw new Error(`irtio: deployment v${ref.version} defines the room type ${type} twice`);
|
|
3611
3613
|
}
|
|
3612
|
-
bundles.set(type, await loadBundle(
|
|
3614
|
+
bundles.set(type, await loadBundle(path3, ref.version, type));
|
|
3613
3615
|
}
|
|
3614
3616
|
const bundle = bundles.get(DEFAULT_ROOM_TYPE3);
|
|
3615
3617
|
const schemaJson = bundle?.schemaJson ?? ref.schemaJson;
|
|
@@ -4004,10 +4006,15 @@ var SupervisorImpl = class {
|
|
|
4004
4006
|
for (const session of this.sessions) {
|
|
4005
4007
|
if (!session.open) continue;
|
|
4006
4008
|
if (session.missedPongs >= WS_PING_MISSES) {
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4009
|
+
session.sendError(
|
|
4010
|
+
"E_IDLE_TIMEOUT",
|
|
4011
|
+
formatError("E_IDLE_TIMEOUT", {
|
|
4012
|
+
misses: WS_PING_MISSES,
|
|
4013
|
+
seconds: Math.round((WS_PING_MISSES + 1) * WS_PING_MS / 1e3)
|
|
4014
|
+
}),
|
|
4015
|
+
false
|
|
4016
|
+
);
|
|
4017
|
+
session.closeSocket(CLOSE_IDLE_TIMEOUT, "E_IDLE_TIMEOUT");
|
|
4011
4018
|
continue;
|
|
4012
4019
|
}
|
|
4013
4020
|
session.missedPongs++;
|
|
@@ -4120,7 +4127,7 @@ var SupervisorImpl = class {
|
|
|
4120
4127
|
const waitMs = this.config.identityKeyWaitMs ?? IDENTITY_KEY_WAIT_MS;
|
|
4121
4128
|
if (waitMs <= 0) return Promise.resolve();
|
|
4122
4129
|
if (this.identityKeyWaiters.length >= MAX_IDENTITY_KEY_WAITERS) return Promise.resolve();
|
|
4123
|
-
return new Promise((
|
|
4130
|
+
return new Promise((resolve3) => {
|
|
4124
4131
|
let done = false;
|
|
4125
4132
|
const finish = () => {
|
|
4126
4133
|
if (done) return;
|
|
@@ -4128,7 +4135,7 @@ var SupervisorImpl = class {
|
|
|
4128
4135
|
clearTimeout(timer);
|
|
4129
4136
|
const at = this.identityKeyWaiters.indexOf(finish);
|
|
4130
4137
|
if (at >= 0) this.identityKeyWaiters.splice(at, 1);
|
|
4131
|
-
|
|
4138
|
+
resolve3();
|
|
4132
4139
|
};
|
|
4133
4140
|
const timer = setTimeout(finish, waitMs);
|
|
4134
4141
|
timer.unref?.();
|
|
@@ -4427,16 +4434,16 @@ var SupervisorImpl = class {
|
|
|
4427
4434
|
onDead: (reason) => this.onWorkerDead(room, reason)
|
|
4428
4435
|
});
|
|
4429
4436
|
room.worker = worker;
|
|
4430
|
-
const outcome = await new Promise((
|
|
4437
|
+
const outcome = await new Promise((resolve3) => {
|
|
4431
4438
|
const timer = setTimeout(() => {
|
|
4432
4439
|
this.readyWaiters.delete(room);
|
|
4433
|
-
|
|
4440
|
+
resolve3(void 0);
|
|
4434
4441
|
}, this.limits.workerReadyTimeoutMs);
|
|
4435
4442
|
timer.unref?.();
|
|
4436
4443
|
this.readyWaiters.set(room, (msg) => {
|
|
4437
4444
|
clearTimeout(timer);
|
|
4438
4445
|
this.readyWaiters.delete(room);
|
|
4439
|
-
|
|
4446
|
+
resolve3(msg);
|
|
4440
4447
|
});
|
|
4441
4448
|
worker.post({
|
|
4442
4449
|
t: "init",
|
|
@@ -5319,16 +5326,16 @@ var SupervisorImpl = class {
|
|
|
5319
5326
|
requestJoin(room, session, reconnecting) {
|
|
5320
5327
|
const worker = room.worker;
|
|
5321
5328
|
if (!worker) return Promise.resolve(void 0);
|
|
5322
|
-
return new Promise((
|
|
5329
|
+
return new Promise((resolve3) => {
|
|
5323
5330
|
const timer = setTimeout(() => {
|
|
5324
5331
|
this.joinWaiters.delete(session.clientId);
|
|
5325
|
-
|
|
5332
|
+
resolve3(void 0);
|
|
5326
5333
|
}, JOIN_TIMEOUT_MS);
|
|
5327
5334
|
timer.unref?.();
|
|
5328
5335
|
this.joinWaiters.set(session.clientId, (msg) => {
|
|
5329
5336
|
clearTimeout(timer);
|
|
5330
5337
|
this.joinWaiters.delete(session.clientId);
|
|
5331
|
-
|
|
5338
|
+
resolve3(msg);
|
|
5332
5339
|
});
|
|
5333
5340
|
worker.post({
|
|
5334
5341
|
t: "join",
|
|
@@ -6098,11 +6105,11 @@ async function loadBundle(bundlePath, version, type) {
|
|
|
6098
6105
|
};
|
|
6099
6106
|
}
|
|
6100
6107
|
function resolveWorkerEntry() {
|
|
6101
|
-
const
|
|
6102
|
-
if (typeof
|
|
6108
|
+
const resolve3 = import.meta.resolve;
|
|
6109
|
+
if (typeof resolve3 !== "function") {
|
|
6103
6110
|
throw new Error("irtio: no workerEntry configured and import.meta.resolve is unavailable");
|
|
6104
6111
|
}
|
|
6105
|
-
return
|
|
6112
|
+
return resolve3("@irtio/runtime/worker");
|
|
6106
6113
|
}
|
|
6107
6114
|
function bundleRefsOf(config) {
|
|
6108
6115
|
if (config.bundles && config.bundles.length > 0) return config.bundles;
|
|
@@ -6271,6 +6278,153 @@ setInterval(tick, 1000);
|
|
|
6271
6278
|
</html>
|
|
6272
6279
|
`;
|
|
6273
6280
|
|
|
6281
|
+
// src/dev-serve.ts
|
|
6282
|
+
import { createReadStream } from "fs";
|
|
6283
|
+
import { readFile, stat } from "fs/promises";
|
|
6284
|
+
import * as http from "http";
|
|
6285
|
+
import * as https from "https";
|
|
6286
|
+
import * as path from "path";
|
|
6287
|
+
var IRT_URL_SNIPPET = "<script>window.IRT_URL ??= (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host;</script>";
|
|
6288
|
+
function injectIrtUrl(html) {
|
|
6289
|
+
const head = /<head[^>]*>/i.exec(html);
|
|
6290
|
+
if (head === null) return IRT_URL_SNIPPET + html;
|
|
6291
|
+
const at = head.index + head[0].length;
|
|
6292
|
+
return html.slice(0, at) + IRT_URL_SNIPPET + html.slice(at);
|
|
6293
|
+
}
|
|
6294
|
+
function resolveServedFile(dir, urlPath) {
|
|
6295
|
+
let decoded;
|
|
6296
|
+
try {
|
|
6297
|
+
decoded = decodeURIComponent(urlPath);
|
|
6298
|
+
} catch {
|
|
6299
|
+
return void 0;
|
|
6300
|
+
}
|
|
6301
|
+
if (decoded.includes("\\") || /[\x00-\x1f\x7f]/.test(decoded)) return void 0;
|
|
6302
|
+
const segments = decoded.split("/").filter((s) => s !== "" && s !== ".");
|
|
6303
|
+
if (segments.some((s) => s === ".." || s === ".irtio" || s === "node_modules")) {
|
|
6304
|
+
return void 0;
|
|
6305
|
+
}
|
|
6306
|
+
const file = path.resolve(dir, ...segments);
|
|
6307
|
+
const root = path.resolve(dir);
|
|
6308
|
+
if (file !== root && !file.startsWith(root + path.sep)) return void 0;
|
|
6309
|
+
return file;
|
|
6310
|
+
}
|
|
6311
|
+
function send(res, status, type, body) {
|
|
6312
|
+
res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
|
|
6313
|
+
res.end(body);
|
|
6314
|
+
}
|
|
6315
|
+
function createClientHandler(source, log) {
|
|
6316
|
+
return (req, res) => {
|
|
6317
|
+
const route = (req.url ?? "").split("?")[0] ?? "";
|
|
6318
|
+
if (route === "/healthz" || route === "/__irt" || route.startsWith("/__irt/")) return false;
|
|
6319
|
+
if (source.mode === "proxy") {
|
|
6320
|
+
proxyRequest(source.target, req, res, log);
|
|
6321
|
+
return true;
|
|
6322
|
+
}
|
|
6323
|
+
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
6324
|
+
serveFile(source.dir, route, req, res);
|
|
6325
|
+
return true;
|
|
6326
|
+
};
|
|
6327
|
+
}
|
|
6328
|
+
function serveFile(dir, route, req, res) {
|
|
6329
|
+
void (async () => {
|
|
6330
|
+
let file = resolveServedFile(dir, route);
|
|
6331
|
+
if (file === void 0) {
|
|
6332
|
+
send(res, 404, "text/plain; charset=utf-8", "not found");
|
|
6333
|
+
return;
|
|
6334
|
+
}
|
|
6335
|
+
try {
|
|
6336
|
+
let info = await stat(file).catch(() => void 0);
|
|
6337
|
+
if (info?.isDirectory() === true) {
|
|
6338
|
+
file = path.join(file, "index.html");
|
|
6339
|
+
info = await stat(file).catch(() => void 0);
|
|
6340
|
+
}
|
|
6341
|
+
if (info === void 0 || !info.isFile()) {
|
|
6342
|
+
send(res, 404, "text/plain; charset=utf-8", `not found: ${route}
|
|
6343
|
+
(served from ${dir})`);
|
|
6344
|
+
return;
|
|
6345
|
+
}
|
|
6346
|
+
const type = staticContentType(file);
|
|
6347
|
+
if (type.startsWith("text/html")) {
|
|
6348
|
+
const body = injectIrtUrl(await readFile(file, "utf8"));
|
|
6349
|
+
res.writeHead(200, {
|
|
6350
|
+
"content-type": type,
|
|
6351
|
+
"content-length": Buffer.byteLength(body),
|
|
6352
|
+
"cache-control": "no-store"
|
|
6353
|
+
});
|
|
6354
|
+
res.end(req.method === "HEAD" ? void 0 : body);
|
|
6355
|
+
return;
|
|
6356
|
+
}
|
|
6357
|
+
res.writeHead(200, {
|
|
6358
|
+
"content-type": type,
|
|
6359
|
+
"content-length": info.size,
|
|
6360
|
+
// Dev files change constantly; a cached stale bundle is exactly the bug class the
|
|
6361
|
+
// rebuild watcher exists to prevent.
|
|
6362
|
+
"cache-control": "no-store"
|
|
6363
|
+
});
|
|
6364
|
+
if (req.method === "HEAD") {
|
|
6365
|
+
res.end();
|
|
6366
|
+
return;
|
|
6367
|
+
}
|
|
6368
|
+
const stream = createReadStream(file);
|
|
6369
|
+
stream.on("error", () => res.destroy());
|
|
6370
|
+
stream.pipe(res);
|
|
6371
|
+
} catch (err) {
|
|
6372
|
+
send(res, 500, "text/plain; charset=utf-8", String(err));
|
|
6373
|
+
}
|
|
6374
|
+
})();
|
|
6375
|
+
}
|
|
6376
|
+
function proxyRequest(target, req, res, log) {
|
|
6377
|
+
const client = target.protocol === "https:" ? https : http;
|
|
6378
|
+
const upstream = client.request(
|
|
6379
|
+
{
|
|
6380
|
+
hostname: target.hostname,
|
|
6381
|
+
port: target.port === "" ? target.protocol === "https:" ? 443 : 80 : Number(target.port),
|
|
6382
|
+
path: req.url ?? "/",
|
|
6383
|
+
method: req.method ?? "GET",
|
|
6384
|
+
headers: {
|
|
6385
|
+
...req.headers,
|
|
6386
|
+
host: target.host,
|
|
6387
|
+
// Identity keeps HTML responses injectable. Dev servers on loopback lose nothing.
|
|
6388
|
+
"accept-encoding": "identity"
|
|
6389
|
+
}
|
|
6390
|
+
},
|
|
6391
|
+
(up) => {
|
|
6392
|
+
const type = up.headers["content-type"] ?? "";
|
|
6393
|
+
const injectable = typeof type === "string" && type.includes("text/html") && up.headers["content-encoding"] === void 0;
|
|
6394
|
+
if (!injectable) {
|
|
6395
|
+
res.writeHead(up.statusCode ?? 502, up.headers);
|
|
6396
|
+
up.pipe(res);
|
|
6397
|
+
return;
|
|
6398
|
+
}
|
|
6399
|
+
const chunks = [];
|
|
6400
|
+
up.on("data", (c) => chunks.push(c));
|
|
6401
|
+
up.on("error", () => res.destroy());
|
|
6402
|
+
up.on("end", () => {
|
|
6403
|
+
const body = injectIrtUrl(Buffer.concat(chunks).toString("utf8"));
|
|
6404
|
+
const { "transfer-encoding": _dropped, ...headers } = up.headers;
|
|
6405
|
+
headers["content-length"] = String(Buffer.byteLength(body));
|
|
6406
|
+
res.writeHead(up.statusCode ?? 502, headers);
|
|
6407
|
+
res.end(body);
|
|
6408
|
+
});
|
|
6409
|
+
}
|
|
6410
|
+
);
|
|
6411
|
+
upstream.on("error", (err) => {
|
|
6412
|
+
log(`irtio dev: proxy to ${target.origin} failed: ${String(err)}`);
|
|
6413
|
+
if (!res.headersSent) {
|
|
6414
|
+
send(
|
|
6415
|
+
res,
|
|
6416
|
+
502,
|
|
6417
|
+
"text/plain; charset=utf-8",
|
|
6418
|
+
`irtio dev could not reach ${target.origin} \u2014 is your dev server running there?`
|
|
6419
|
+
);
|
|
6420
|
+
} else {
|
|
6421
|
+
res.destroy();
|
|
6422
|
+
}
|
|
6423
|
+
});
|
|
6424
|
+
req.pipe(upstream);
|
|
6425
|
+
req.on("error", () => upstream.destroy());
|
|
6426
|
+
}
|
|
6427
|
+
|
|
6274
6428
|
// src/dev.ts
|
|
6275
6429
|
var ROOM_CANDIDATES = ["irtio/room.ts", "irtio/room.js", "room.ts"];
|
|
6276
6430
|
var DEFAULT_PORT = 7070;
|
|
@@ -6279,12 +6433,12 @@ var WATCHED_EXTENSIONS = [".ts", ".js", ".mts", ".mjs", ".tsx", ".jsx"];
|
|
|
6279
6433
|
var LOG_LIMIT = 100;
|
|
6280
6434
|
function resolveEntry(cwd, room) {
|
|
6281
6435
|
if (room !== void 0) {
|
|
6282
|
-
const file =
|
|
6436
|
+
const file = path2.resolve(cwd, room);
|
|
6283
6437
|
if (!existsSync(file)) throw new Error(`irtio dev: no room file at ${file}`);
|
|
6284
6438
|
return file;
|
|
6285
6439
|
}
|
|
6286
6440
|
for (const candidate of ROOM_CANDIDATES) {
|
|
6287
|
-
const file =
|
|
6441
|
+
const file = path2.resolve(cwd, candidate);
|
|
6288
6442
|
if (existsSync(file)) return file;
|
|
6289
6443
|
}
|
|
6290
6444
|
throw new Error(
|
|
@@ -6296,11 +6450,11 @@ function resolveEntry(cwd, room) {
|
|
|
6296
6450
|
async function warnIfClientImportsRoom(cwd, entry, config, log) {
|
|
6297
6451
|
const client = config.client;
|
|
6298
6452
|
if (client === void 0) return;
|
|
6299
|
-
const clientFile =
|
|
6453
|
+
const clientFile = path2.resolve(cwd, client);
|
|
6300
6454
|
if (await clientImportsRoom(clientFile, entry)) {
|
|
6301
6455
|
log(
|
|
6302
6456
|
pc.yellow(
|
|
6303
|
-
`irtio: WARNING: the client entry ${client} imports the room file ${
|
|
6457
|
+
`irtio: WARNING: the client entry ${client} imports the room file ${path2.relative(cwd, entry)} \u2014 room code must never ship to clients; move shared code (e.g. the physics world builder) into its own module both sides import`
|
|
6304
6458
|
)
|
|
6305
6459
|
);
|
|
6306
6460
|
}
|
|
@@ -6308,7 +6462,7 @@ async function warnIfClientImportsRoom(cwd, entry, config, log) {
|
|
|
6308
6462
|
function engineAbsolutePlugin(fromPackageDir, pkg) {
|
|
6309
6463
|
let resolved;
|
|
6310
6464
|
try {
|
|
6311
|
-
resolved = createRequire(
|
|
6465
|
+
resolved = createRequire(path2.join(fromPackageDir, "package.json")).resolve(pkg);
|
|
6312
6466
|
} catch {
|
|
6313
6467
|
return void 0;
|
|
6314
6468
|
}
|
|
@@ -6333,15 +6487,15 @@ async function resolveWorkerEntry2(outDir) {
|
|
|
6333
6487
|
} catch {
|
|
6334
6488
|
}
|
|
6335
6489
|
}
|
|
6336
|
-
const packagesDir =
|
|
6337
|
-
const source =
|
|
6490
|
+
const packagesDir = path2.resolve(path2.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
6491
|
+
const source = path2.join(packagesDir, "runtime/src/worker/index.ts");
|
|
6338
6492
|
if (!existsSync(source)) {
|
|
6339
6493
|
throw new Error(
|
|
6340
6494
|
"irtio dev: could not resolve @irtio/runtime/worker \u2014 is @irtio/runtime installed and built?"
|
|
6341
6495
|
);
|
|
6342
6496
|
}
|
|
6343
|
-
const outfile =
|
|
6344
|
-
const runtimeDir =
|
|
6497
|
+
const outfile = path2.join(outDir, "worker.mjs");
|
|
6498
|
+
const runtimeDir = path2.join(packagesDir, "runtime");
|
|
6345
6499
|
const enginePlugins = ["@dimforge/rapier3d-compat", "matter-js", "@dimforge/rapier2d-compat"].map((pkg) => engineAbsolutePlugin(runtimeDir, pkg)).filter((p) => p !== void 0);
|
|
6346
6500
|
await esbuild.build({
|
|
6347
6501
|
entryPoints: [source],
|
|
@@ -6350,9 +6504,9 @@ async function resolveWorkerEntry2(outDir) {
|
|
|
6350
6504
|
platform: "node",
|
|
6351
6505
|
outfile,
|
|
6352
6506
|
alias: {
|
|
6353
|
-
"@irtio/schema":
|
|
6354
|
-
"@irtio/server":
|
|
6355
|
-
"@irtio/protocol":
|
|
6507
|
+
"@irtio/schema": path2.join(packagesDir, "schema/src/index.ts"),
|
|
6508
|
+
"@irtio/server": path2.join(packagesDir, "server/src/index.ts"),
|
|
6509
|
+
"@irtio/protocol": path2.join(packagesDir, "protocol/src/index.ts")
|
|
6356
6510
|
},
|
|
6357
6511
|
...enginePlugins.length > 0 ? { plugins: enginePlugins } : {},
|
|
6358
6512
|
// Rapier stays external for the same reason it does in packages/supervisor/test/support.ts:
|
|
@@ -6412,19 +6566,102 @@ function safeJson(value) {
|
|
|
6412
6566
|
return String(value);
|
|
6413
6567
|
}
|
|
6414
6568
|
}
|
|
6415
|
-
function
|
|
6569
|
+
function send2(res, status, type, body) {
|
|
6416
6570
|
res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
|
|
6417
6571
|
res.end(body);
|
|
6418
6572
|
}
|
|
6573
|
+
async function tryCommand(cmd, args) {
|
|
6574
|
+
try {
|
|
6575
|
+
const { execFile: execFile2 } = await import("child_process");
|
|
6576
|
+
return await new Promise((resolve3) => {
|
|
6577
|
+
const child = execFile2(
|
|
6578
|
+
cmd,
|
|
6579
|
+
[...args],
|
|
6580
|
+
{ timeout: PORT_LOOKUP_TIMEOUT_MS, windowsHide: true },
|
|
6581
|
+
(err, stdout) => resolve3(err ? void 0 : stdout)
|
|
6582
|
+
);
|
|
6583
|
+
child.on("error", () => resolve3(void 0));
|
|
6584
|
+
});
|
|
6585
|
+
} catch {
|
|
6586
|
+
return void 0;
|
|
6587
|
+
}
|
|
6588
|
+
}
|
|
6589
|
+
var PORT_LOOKUP_TIMEOUT_MS = 1500;
|
|
6590
|
+
async function whoHoldsPort(port) {
|
|
6591
|
+
try {
|
|
6592
|
+
let pid;
|
|
6593
|
+
if (process.platform === "win32") {
|
|
6594
|
+
const out2 = await tryCommand("netstat", ["-ano", "-p", "tcp"]);
|
|
6595
|
+
for (const line of (out2 ?? "").split(/\r?\n/)) {
|
|
6596
|
+
const cols = line.trim().split(/\s+/);
|
|
6597
|
+
if (cols.length < 5 || cols[3] !== "LISTENING") continue;
|
|
6598
|
+
if (!(cols[1] ?? "").endsWith(`:${port}`)) continue;
|
|
6599
|
+
const n2 = Number(cols[cols.length - 1]);
|
|
6600
|
+
if (Number.isInteger(n2) && n2 > 0) pid = n2;
|
|
6601
|
+
break;
|
|
6602
|
+
}
|
|
6603
|
+
if (pid === void 0) return void 0;
|
|
6604
|
+
const tasks = await tryCommand("tasklist", ["/FI", `PID eq ${pid}`, "/NH", "/FO", "CSV"]);
|
|
6605
|
+
const name2 = /^"([^"]+)"/.exec((tasks ?? "").trim())?.[1];
|
|
6606
|
+
return name2 ? `port ${port} is held by ${name2} (pid ${pid})` : `port ${port} is held by pid ${pid}`;
|
|
6607
|
+
}
|
|
6608
|
+
const out = await tryCommand("lsof", ["-i", `:${port}`, "-t", "-sTCP:LISTEN"]);
|
|
6609
|
+
const first = (out ?? "").split(/\s+/).filter(Boolean)[0];
|
|
6610
|
+
const n = Number(first);
|
|
6611
|
+
if (!Number.isInteger(n) || n <= 0) return void 0;
|
|
6612
|
+
pid = n;
|
|
6613
|
+
const ps = await tryCommand("ps", ["-p", String(pid), "-o", "comm="]);
|
|
6614
|
+
const name = (ps ?? "").trim().split(/\s+/)[0];
|
|
6615
|
+
return name ? `port ${port} is held by ${name} (pid ${pid})` : `port ${port} is held by pid ${pid}`;
|
|
6616
|
+
} catch {
|
|
6617
|
+
return void 0;
|
|
6618
|
+
}
|
|
6619
|
+
}
|
|
6620
|
+
function resolveClientSource(cwd, config, options, log) {
|
|
6621
|
+
if (typeof options.serve === "string" && options.proxy !== void 0) {
|
|
6622
|
+
throw new Error("irtio dev: --serve and --proxy are exclusive \u2014 the port has one client side");
|
|
6623
|
+
}
|
|
6624
|
+
if (options.proxy !== void 0) {
|
|
6625
|
+
let target;
|
|
6626
|
+
try {
|
|
6627
|
+
target = new URL(options.proxy);
|
|
6628
|
+
} catch {
|
|
6629
|
+
throw new Error(`irtio dev: --proxy needs a URL, got ${JSON.stringify(options.proxy)}`);
|
|
6630
|
+
}
|
|
6631
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
6632
|
+
throw new Error(`irtio dev: --proxy must be an http(s) URL, got ${options.proxy}`);
|
|
6633
|
+
}
|
|
6634
|
+
return { mode: "proxy", target };
|
|
6635
|
+
}
|
|
6636
|
+
if (typeof options.serve === "string") {
|
|
6637
|
+
const dir2 = path2.resolve(cwd, options.serve);
|
|
6638
|
+
if (!existsSync(dir2)) throw new Error(`irtio dev: --serve: no directory at ${dir2}`);
|
|
6639
|
+
return { mode: "serve", dir: dir2 };
|
|
6640
|
+
}
|
|
6641
|
+
if (options.serve === false || config.static === void 0) return void 0;
|
|
6642
|
+
const dir = path2.resolve(cwd, config.static.dir);
|
|
6643
|
+
if (!existsSync(path2.join(dir, "index.html"))) return void 0;
|
|
6644
|
+
log(
|
|
6645
|
+
pc.dim(
|
|
6646
|
+
`irtio dev: serving ${config.static.dir} (irtio.json "static"); --no-serve turns this off`
|
|
6647
|
+
)
|
|
6648
|
+
);
|
|
6649
|
+
return { mode: "serve", dir };
|
|
6650
|
+
}
|
|
6419
6651
|
async function startDev(options = {}) {
|
|
6420
|
-
const cwd =
|
|
6652
|
+
const cwd = path2.resolve(options.cwd ?? process.cwd());
|
|
6421
6653
|
const startedAt = Date.now();
|
|
6422
6654
|
const log = options.log ?? ((line) => console.log(line));
|
|
6423
6655
|
const entry = resolveEntry(cwd, options.room);
|
|
6424
6656
|
const config = await readProjectConfig(cwd, "irtio dev", options.config);
|
|
6425
6657
|
const projectId = config.project ?? "dev";
|
|
6426
|
-
const outDir =
|
|
6658
|
+
const outDir = path2.join(cwd, ".irtio", "dev");
|
|
6427
6659
|
const port = options.port ?? DEFAULT_PORT;
|
|
6660
|
+
const stateDir = path2.join(cwd, ".irtio", "snapshots");
|
|
6661
|
+
if (options.resetState === true) {
|
|
6662
|
+
await rm(stateDir, { recursive: true, force: true });
|
|
6663
|
+
log(pc.dim(`irtio dev: reset ${path2.relative(cwd, stateDir)}`));
|
|
6664
|
+
}
|
|
6428
6665
|
await mkdir(outDir, { recursive: true });
|
|
6429
6666
|
await warnIfClientImportsRoom(cwd, entry, config, log);
|
|
6430
6667
|
const bundleOptions = {
|
|
@@ -6441,11 +6678,14 @@ async function startDev(options = {}) {
|
|
|
6441
6678
|
mode: first.mode
|
|
6442
6679
|
};
|
|
6443
6680
|
const workerEntry = await resolveWorkerEntry2(outDir);
|
|
6681
|
+
const clientSource = resolveClientSource(cwd, config, options, log);
|
|
6682
|
+
const clientHandler = clientSource === void 0 ? void 0 : createClientHandler(clientSource, log);
|
|
6444
6683
|
const httpHandler = (req, res) => {
|
|
6684
|
+
if (clientHandler?.(req, res) === true) return true;
|
|
6445
6685
|
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
6446
6686
|
const route = (req.url ?? "").split("?")[0];
|
|
6447
6687
|
if (route === "/__irt" || route === "/__irt/") {
|
|
6448
|
-
|
|
6688
|
+
send2(res, 200, "text/html; charset=utf-8", DEV_PAGE);
|
|
6449
6689
|
return true;
|
|
6450
6690
|
}
|
|
6451
6691
|
if (route === "/__irt/record.json" || route === "/__irt/timeline.json") {
|
|
@@ -6460,7 +6700,7 @@ async function startDev(options = {}) {
|
|
|
6460
6700
|
...Number.isInteger(maxTicks) && maxTicks > 0 ? { maxTicks } : {},
|
|
6461
6701
|
...Number.isInteger(maxRecords) && maxRecords > 0 ? { maxRecords } : {}
|
|
6462
6702
|
});
|
|
6463
|
-
|
|
6703
|
+
send2(
|
|
6464
6704
|
res,
|
|
6465
6705
|
started ? 200 : 404,
|
|
6466
6706
|
"application/json; charset=utf-8",
|
|
@@ -6473,7 +6713,7 @@ async function startDev(options = {}) {
|
|
|
6473
6713
|
return;
|
|
6474
6714
|
}
|
|
6475
6715
|
const dump = await supervisor.readTimeline(roomId);
|
|
6476
|
-
|
|
6716
|
+
send2(
|
|
6477
6717
|
res,
|
|
6478
6718
|
dump === void 0 ? 404 : 200,
|
|
6479
6719
|
"application/json; charset=utf-8",
|
|
@@ -6482,7 +6722,7 @@ async function startDev(options = {}) {
|
|
|
6482
6722
|
)
|
|
6483
6723
|
);
|
|
6484
6724
|
} catch (err) {
|
|
6485
|
-
|
|
6725
|
+
send2(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
|
|
6486
6726
|
}
|
|
6487
6727
|
})();
|
|
6488
6728
|
return true;
|
|
@@ -6493,7 +6733,7 @@ async function startDev(options = {}) {
|
|
|
6493
6733
|
void (async () => {
|
|
6494
6734
|
try {
|
|
6495
6735
|
const saved = await supervisor.saveNow(roomId);
|
|
6496
|
-
|
|
6736
|
+
send2(
|
|
6497
6737
|
res,
|
|
6498
6738
|
saved === void 0 ? 404 : 200,
|
|
6499
6739
|
"application/json; charset=utf-8",
|
|
@@ -6506,7 +6746,7 @@ async function startDev(options = {}) {
|
|
|
6506
6746
|
)
|
|
6507
6747
|
);
|
|
6508
6748
|
} catch (err) {
|
|
6509
|
-
|
|
6749
|
+
send2(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
|
|
6510
6750
|
}
|
|
6511
6751
|
})();
|
|
6512
6752
|
return true;
|
|
@@ -6514,14 +6754,14 @@ async function startDev(options = {}) {
|
|
|
6514
6754
|
if (route === "/__irt/state.json") {
|
|
6515
6755
|
void (async () => {
|
|
6516
6756
|
try {
|
|
6517
|
-
|
|
6757
|
+
send2(
|
|
6518
6758
|
res,
|
|
6519
6759
|
200,
|
|
6520
6760
|
"application/json; charset=utf-8",
|
|
6521
6761
|
JSON.stringify(await buildState(supervisor, projectId, bundle, startedAt))
|
|
6522
6762
|
);
|
|
6523
6763
|
} catch (err) {
|
|
6524
|
-
|
|
6764
|
+
send2(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
|
|
6525
6765
|
}
|
|
6526
6766
|
})();
|
|
6527
6767
|
return true;
|
|
@@ -6534,9 +6774,9 @@ async function startDev(options = {}) {
|
|
|
6534
6774
|
bundlePath: bundle.file,
|
|
6535
6775
|
origins: ["*"],
|
|
6536
6776
|
port,
|
|
6537
|
-
host: "127.0.0.1",
|
|
6777
|
+
host: options.host ?? "127.0.0.1",
|
|
6538
6778
|
tenantIdleMs: 0,
|
|
6539
|
-
store: new DiskStore(
|
|
6779
|
+
store: new DiskStore(stateDir),
|
|
6540
6780
|
publicUrl: `http://localhost:${port}`,
|
|
6541
6781
|
httpHandler,
|
|
6542
6782
|
...options.profile === true ? { profile: true } : {},
|
|
@@ -6552,8 +6792,10 @@ async function startDev(options = {}) {
|
|
|
6552
6792
|
const code = err?.code;
|
|
6553
6793
|
if (code === "EADDRINUSE" || code === "EACCES") {
|
|
6554
6794
|
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`)";
|
|
6795
|
+
const holder = code === "EADDRINUSE" ? await whoHoldsPort(port) : void 0;
|
|
6555
6796
|
throw new Error(
|
|
6556
|
-
`irtio dev: cannot bind port ${port} \u2014 ${why}
|
|
6797
|
+
`irtio dev: cannot bind port ${port} \u2014 ${why}.` + (holder ? `
|
|
6798
|
+
${holder}` : "") + `
|
|
6557
6799
|
run: irtio dev --port <other port>
|
|
6558
6800
|
and point your page at it with window.IRT_URL = 'ws://localhost:<other port>'`
|
|
6559
6801
|
);
|
|
@@ -6563,7 +6805,7 @@ async function startDev(options = {}) {
|
|
|
6563
6805
|
const watcher = options.watch === false ? void 0 : startWatch();
|
|
6564
6806
|
let stopped = false;
|
|
6565
6807
|
function startWatch() {
|
|
6566
|
-
const dir =
|
|
6808
|
+
const dir = path2.dirname(entry);
|
|
6567
6809
|
let timer;
|
|
6568
6810
|
let rebuilding = Promise.resolve();
|
|
6569
6811
|
let handle;
|
|
@@ -6572,7 +6814,7 @@ async function startDev(options = {}) {
|
|
|
6572
6814
|
if (filename === null) return;
|
|
6573
6815
|
const name = filename.toString();
|
|
6574
6816
|
if (name.split(/[\\/]/).some((p) => p === ".irtio" || p === "node_modules")) return;
|
|
6575
|
-
if (!WATCHED_EXTENSIONS.includes(
|
|
6817
|
+
if (!WATCHED_EXTENSIONS.includes(path2.extname(name))) return;
|
|
6576
6818
|
if (timer) clearTimeout(timer);
|
|
6577
6819
|
timer = setTimeout(() => {
|
|
6578
6820
|
timer = void 0;
|
|
@@ -6605,9 +6847,30 @@ async function startDev(options = {}) {
|
|
|
6605
6847
|
}
|
|
6606
6848
|
}
|
|
6607
6849
|
const boundPort = supervisor.port;
|
|
6850
|
+
const devFile = path2.join(cwd, ".irtio", "dev.json");
|
|
6851
|
+
const clientUrl = clientSource === void 0 ? void 0 : `http://localhost:${boundPort}/`;
|
|
6852
|
+
await writeFile2(
|
|
6853
|
+
devFile,
|
|
6854
|
+
`${JSON.stringify(
|
|
6855
|
+
{
|
|
6856
|
+
port: boundPort,
|
|
6857
|
+
url: `ws://localhost:${boundPort}`,
|
|
6858
|
+
page: `http://localhost:${boundPort}/__irt/`,
|
|
6859
|
+
...clientUrl !== void 0 ? { client: clientUrl } : {},
|
|
6860
|
+
projectId,
|
|
6861
|
+
pid: process.pid,
|
|
6862
|
+
startedAt: new Date(startedAt).toISOString()
|
|
6863
|
+
},
|
|
6864
|
+
null,
|
|
6865
|
+
2
|
|
6866
|
+
)}
|
|
6867
|
+
`
|
|
6868
|
+
).catch(() => {
|
|
6869
|
+
});
|
|
6608
6870
|
return {
|
|
6609
6871
|
url: `ws://localhost:${boundPort}`,
|
|
6610
6872
|
page: `http://localhost:${boundPort}/__irt/`,
|
|
6873
|
+
...clientUrl !== void 0 ? { client: clientUrl } : {},
|
|
6611
6874
|
port: boundPort,
|
|
6612
6875
|
projectId,
|
|
6613
6876
|
supervisor,
|
|
@@ -6615,6 +6878,8 @@ async function startDev(options = {}) {
|
|
|
6615
6878
|
if (stopped) return;
|
|
6616
6879
|
stopped = true;
|
|
6617
6880
|
watcher?.close();
|
|
6881
|
+
await rm(devFile, { force: true }).catch(() => {
|
|
6882
|
+
});
|
|
6618
6883
|
await supervisor.flushAll().catch(() => {
|
|
6619
6884
|
});
|
|
6620
6885
|
await supervisor.close();
|
|
@@ -6629,7 +6894,13 @@ port. Rebuilds and resets the rooms whenever the room code changes.
|
|
|
6629
6894
|
options:
|
|
6630
6895
|
--room <file> the room entry (default: irtio/room.ts, irtio/room.js, room.ts)
|
|
6631
6896
|
--port <n> port to listen on (default 7070; 0 picks a free one)
|
|
6897
|
+
--host <address> address to bind (default 127.0.0.1; 0.0.0.0 for LAN playtests)
|
|
6898
|
+
--serve <dir> serve a static client from <dir> on the same port (default: irtio.json's
|
|
6899
|
+
"static" directory, when it has an index.html)
|
|
6900
|
+
--no-serve do not serve the irtio.json "static" directory
|
|
6901
|
+
--proxy <url> forward non-irtio HTTP to your own dev server (e.g. http://localhost:5173)
|
|
6632
6902
|
--no-watch do not rebuild on change
|
|
6903
|
+
--reset-state delete .irtio/snapshots first, so every room starts fresh
|
|
6633
6904
|
-c, --config <f> the project file to read (default irtio.json)
|
|
6634
6905
|
--profile print a bandwidth breakdown per room once a second
|
|
6635
6906
|
--profile-top <n> rows per table (default 12; implies --profile)
|
|
@@ -6637,7 +6908,7 @@ options:
|
|
|
6637
6908
|
`;
|
|
6638
6909
|
function parseDevArgs(args) {
|
|
6639
6910
|
if (helpRequested(args)) throw helpFor(USAGE);
|
|
6640
|
-
const parsed = { watch: true, profile: false };
|
|
6911
|
+
const parsed = { watch: true, resetState: false, profile: false };
|
|
6641
6912
|
for (let i = 0; i < args.length; i++) {
|
|
6642
6913
|
const arg = args[i];
|
|
6643
6914
|
const eq = arg.indexOf("=");
|
|
@@ -6660,9 +6931,24 @@ function parseDevArgs(args) {
|
|
|
6660
6931
|
parsed.port = port;
|
|
6661
6932
|
break;
|
|
6662
6933
|
}
|
|
6934
|
+
case "--host":
|
|
6935
|
+
parsed.host = value();
|
|
6936
|
+
break;
|
|
6937
|
+
case "--serve":
|
|
6938
|
+
parsed.serve = value();
|
|
6939
|
+
break;
|
|
6940
|
+
case "--no-serve":
|
|
6941
|
+
parsed.serve = false;
|
|
6942
|
+
break;
|
|
6943
|
+
case "--proxy":
|
|
6944
|
+
parsed.proxy = value();
|
|
6945
|
+
break;
|
|
6663
6946
|
case "--no-watch":
|
|
6664
6947
|
parsed.watch = false;
|
|
6665
6948
|
break;
|
|
6949
|
+
case "--reset-state":
|
|
6950
|
+
parsed.resetState = true;
|
|
6951
|
+
break;
|
|
6666
6952
|
case "--profile":
|
|
6667
6953
|
parsed.profile = true;
|
|
6668
6954
|
break;
|
|
@@ -6692,10 +6978,14 @@ async function dev(args) {
|
|
|
6692
6978
|
parsed = parseDevArgs(args);
|
|
6693
6979
|
server = await startDev({
|
|
6694
6980
|
watch: parsed.watch,
|
|
6981
|
+
...parsed.resetState ? { resetState: true } : {},
|
|
6695
6982
|
...parsed.room !== void 0 ? { room: parsed.room } : {},
|
|
6696
6983
|
...parsed.port !== void 0 ? { port: parsed.port } : {},
|
|
6984
|
+
...parsed.host !== void 0 ? { host: parsed.host } : {},
|
|
6697
6985
|
...parsed.config !== void 0 ? { config: parsed.config } : {},
|
|
6698
|
-
...parsed.profile ? { profile: true } : {}
|
|
6986
|
+
...parsed.profile ? { profile: true } : {},
|
|
6987
|
+
...parsed.serve !== void 0 ? { serve: parsed.serve } : {},
|
|
6988
|
+
...parsed.proxy !== void 0 ? { proxy: parsed.proxy } : {}
|
|
6699
6989
|
});
|
|
6700
6990
|
} catch (err) {
|
|
6701
6991
|
if (err instanceof HelpRequested) {
|
|
@@ -6708,11 +6998,24 @@ async function dev(args) {
|
|
|
6708
6998
|
}
|
|
6709
6999
|
console.log(server.url);
|
|
6710
7000
|
console.log(server.page);
|
|
7001
|
+
if (server.client !== void 0) console.log(`${server.client} ${pc.dim("(your game)")}`);
|
|
6711
7002
|
console.log(
|
|
6712
7003
|
pc.dim(
|
|
6713
7004
|
`share links: http://localhost:${server.port}/?room=CODE (codes appear when a client joins)`
|
|
6714
7005
|
)
|
|
6715
7006
|
);
|
|
7007
|
+
if (parsed.host !== void 0 && parsed.host !== "127.0.0.1" && parsed.host !== "localhost") {
|
|
7008
|
+
const { networkInterfaces } = await import("os");
|
|
7009
|
+
const lan = Object.values(networkInterfaces()).flat().filter((a) => a !== void 0 && a.family === "IPv4" && !a.internal).map((a) => `${a.address}:${server.port}`);
|
|
7010
|
+
if (server.client !== void 0) {
|
|
7011
|
+
for (const addr of lan) console.log(pc.dim(`LAN: http://${addr}/`));
|
|
7012
|
+
} else {
|
|
7013
|
+
for (const addr of lan) console.log(pc.dim(`LAN: ws://${addr}`));
|
|
7014
|
+
if (lan.length > 0) {
|
|
7015
|
+
console.log(pc.dim(`point LAN pages at it with window.IRT_URL = 'ws://${lan[0]}'`));
|
|
7016
|
+
}
|
|
7017
|
+
}
|
|
7018
|
+
}
|
|
6716
7019
|
let profileTimer;
|
|
6717
7020
|
if (parsed.profile) {
|
|
6718
7021
|
let history = /* @__PURE__ */ new Map();
|
|
@@ -6763,5 +7066,6 @@ export {
|
|
|
6763
7066
|
USAGE,
|
|
6764
7067
|
dev,
|
|
6765
7068
|
parseDevArgs,
|
|
6766
|
-
startDev
|
|
7069
|
+
startDev,
|
|
7070
|
+
whoHoldsPort
|
|
6767
7071
|
};
|
package/dist/index.js
CHANGED
|
@@ -135,7 +135,7 @@ switch (command) {
|
|
|
135
135
|
break;
|
|
136
136
|
}
|
|
137
137
|
case "dev": {
|
|
138
|
-
const { dev } = await import("./dev-
|
|
138
|
+
const { dev } = await import("./dev-6WP6FP5N.js");
|
|
139
139
|
await dev(args);
|
|
140
140
|
break;
|
|
141
141
|
}
|
|
@@ -156,7 +156,7 @@ switch (command) {
|
|
|
156
156
|
}
|
|
157
157
|
case "deploy": {
|
|
158
158
|
if (args.includes("--static")) {
|
|
159
|
-
const { staticDeploy } = await import("./static-deploy-
|
|
159
|
+
const { staticDeploy } = await import("./static-deploy-OHDBVLA7.js");
|
|
160
160
|
await staticDeploy(args);
|
|
161
161
|
break;
|
|
162
162
|
}
|
package/dist/init.js
CHANGED
|
@@ -15,7 +15,7 @@ import { createInterface } from "readline/promises";
|
|
|
15
15
|
import pc from "picocolors";
|
|
16
16
|
|
|
17
17
|
// src/agent-guide.generated.ts
|
|
18
|
-
var AGENT_GUIDE = "# irt.io integration guide\n\nThe whole product in one page, written for a context window. Everything below is enough to take a\nsingle-player web game to a deployed, verified multiplayer room without opening a dashboard.\n\n## The mental model\n\nA **room** is a server-side TypeScript file. It owns a small typed **schema**: the state two\nplayers have to agree on, and nothing else about your game. Each client owns its own instances and\nwrites them like local objects. The room's `validate` decides what a write may be. Everything else\nin your game stays where it was. You verify a room the way you verify code. Bots drive real\nsockets, a **scenario** asserts against the server's own recorded timeline, and the same seed gives\nthe same verdict.\n\n## The skeleton\n\nThe smallest project that deploys. Four files, and `irtio init` writes all of them.\n\n```ts\n// irtio/schema.ts\nimport { defineSchema, entity, f32, str, u8 } from '@irtio/schema';\n\nimport { rpc } from './rpc.js';\n\nexport const schema = defineSchema(\n {\n // One instance per connected client, owned by that client. Owned means writable on that\n // client and read-only everywhere else.\n players: entity({ x: f32, y: f32, name: str(24), color: u8 }),\n },\n {\n project: 'p_c0ffee1234abcd56', // written by `irtio init`; public, domain-locked, not a secret\n roles: ['player'] as const,\n rpc,\n },\n);\n```\n\n```ts\n// irtio/rpc.ts\nimport { server, u8 } from '@irtio/schema';\n\n// Typed calls in both directions. `server(...)` is client to server. Names and signatures are\n// part of the schema hash, so skew is caught as a version error instead of at runtime.\nexport const rpc = {\n cheer: server({ params: { volume: u8 } }),\n};\n```\n\n```ts\n// irtio/room.ts\nimport { defineRoom } from '@irtio/server';\n\nimport { schema } from './schema.js';\n\nconst WIDTH = 800;\nconst HEIGHT = 500;\n\nexport default defineRoom(schema, {\n mode: 'tick', // 'event' if nothing moves without a player doing something\n tickRate: 20,\n\n onJoin(state, ctx) {\n if (ctx.reconnecting) return;\n state.players.add(\n ctx.clientId,\n { x: 0, y: 0, name: ctx.name || 'anon', color: (ctx.tick * 37) % 256 },\n { owner: ctx.clientId }, // the line the whole client-side write model rests on\n );\n },\n\n onLeave(state, ctx) {\n state.players.remove(ctx.clientId);\n },\n\n // Owner writes pass through here before they are accepted. Return `next` to accept, `prev` to\n // reject, or a clamped object. This is where cheating stops. Delete it and anything that fits\n // the declared types is accepted.\n validate: {\n players(prev, next) {\n // Refuse what could never be a real value, lock the fields the room assigned at join,\n // and clamp the rest into the world.\n if (!Number.isFinite(next.x) || !Number.isFinite(next.y)) return prev;\n if (next.name !== prev.name || next.color !== prev.color) return prev;\n return {\n ...next,\n x: Math.min(WIDTH, Math.max(0, next.x)),\n y: Math.min(HEIGHT, Math.max(0, next.y)),\n };\n },\n },\n\n rpc: {\n cheer(state, params, ctx) {\n if (params.volume > 10) throw new Error('too loud'); // the normal way a room says no\n state.players.get(ctx.clientId)!.color = params.volume;\n },\n },\n\n tick() {}, // tick mode needs one, even empty\n});\n```\n\n```ts\n// your game, three lines added\nimport { joinRoom } from '@irtio/client';\n\nimport { schema } from './irtio/schema.js';\n\nconst room = await joinRoom(schema, { name: 'you' }); // reads ?room=, or creates one\nconst me = room.state.players[room.me]; // yours: write it like a local object\nif (me) me.x = 10;\n\nfor (const [, p] of room.render.players) draw(p); // `render` interpolates everyone else\n```\n\n`room.state` is the authoritative read path for game logic and tests. `room.render` has the same\nshapes and reads non-owned entities a beat behind arrival, interpolated, which is what you draw\nfrom. Writes are batched once per animation frame, and `room.flush()` forces one out now.\n`room.leave()` closes the session. A page rarely needs it; a test with several sessions always\ndoes. `joinRoom`'s options are `{ name, role, url, key, token, physics, transport }`; see\nthe client reference.\n\n`@irtio/client` is a module, so the page that imports it needs whatever bundler your game already\nuses. There is no `irtio build`.\n\n### Write the validate rule your game actually has\n\n`validate` is the one part of the skeleton you cannot copy without thinking. The wrong rule here\nis worse than no rule, because it refuses honest play silently. The server sends a correction, and\nthe thing does not go where the player put it.\n\nA **speed limit** (`if (Math.hypot(next.x - prev.x, next.y - prev.y) > MAX_STEP) return prev;`) is\nright when the thing being moved has a movement speed the game defines: a character, a vehicle, a\ndragged token. It is wrong for a **mouse cursor**. A cursor legitimately teleports when the pointer moves fast,\nor when it leaves the window and comes back. A player gains nothing by putting their cursor\nsomewhere.\n\nAsk what a cheat would actually win. Position, for a cursor, wins nothing, so clamp it and stop.\nPosition for a character wins map knowledge and reach, so limit the step. Score, turn order, hit\ndetection and inventory win the game outright, so they should not be client-owned at all: make\nthem `serverOwned` and change them through an RPC.\n\n## The four commands\n\n```bash\nnpx irtio init # scaffold the four files above plus irtio.json\nnpx irtio dev # bundle irtio/room.ts and run it locally\nnpx irtio simulate --scenario irtio/scenario.ts # real bots, your assertions, exit code\nnpx irtio deploy # create the project if new, classify, upload\n```\n\n`simulate` drives real clients at a room that is **already running**, so `dev` stays up in another\nterminal while you run it. The four are a loop, not a pipeline.\n\nEvery subcommand takes `--help`, and the per-command help is the complete list; the top-level\nsummary is a map.\n\n`dev` is the whole server, locally: it bundles the room file and runs it the way the hosted\nserver does, so it is what you develop and test against. It does not build or serve your\n**client**. That stays your own bundler's job, the same one your game already uses.\n\n`deploy` is the hosted path and needs `irtio login`. There is no offline `deploy`. Without an\naccount, `dev` plus your own bundler is as far as you get, and that runs everything below.\n\n`deploy` creates the project on first use and classifies the schema change against the last\ndeployment. It refuses breaking changes unless a migration covers them, then prints where the room\nis playable. `simulate` exits non-zero on a violation, so both belong in CI.\n\nWhen the question is \"why is this room using so much bandwidth\", both commands take `--profile`.\n`dev --profile` prints a per-room breakdown by collection and field once a second.\n`simulate --profile` prints one for the whole run as the bots saw it. The answer is usually a\nsingle wide field, or `overhead` telling you the updates are too small and too frequent. See\n[the profiler](/docs/guides/profiler).\n\n**Restart `dev` after editing the room.** With `--no-watch`, a `dev` you forgot to kill keeps\nserving the old bundle. `simulate` connects to it and reports a clean run against code you have\nreplaced. The report header names the bundle hash and how long the server has been up. Compare\nthose two lines between runs: an edit that changed nothing in the header changed nothing in the\ncode under test.\n\nAn agent driving this through MCP calls `project_create`, `origin_add`, `deploy`, `scenario_run`,\nthen `logs` or `metrics`. Sign in once with `npx irtio login`; the MCP config snippet is a command\nand carries no secret.\n\n## Verify a room with a scenario\n\nA scenario is a TypeScript module next to the room file. Assertions run against the recorded\nauthoritative timeline, one frame per server tick. A scenario can prove something about a race no\nclient could observe, and it replays: same seed, same verdict.\n\n```ts\n// irtio/scenario.ts\nimport { defineScenario } from '@irtio/bots';\n\nimport type { schema } from './schema.js';\n\nexport default defineScenario<typeof schema>({\n bots: 2,\n seconds: 4,\n seed: 41,\n\n // Adversarial in two words: bot 0 lags, and every bot tries illegal writes. `validate` above\n // is what has to refuse them.\n conditions: (index) => (index === 0 ? { rttMs: 200 } : undefined),\n cheat: true,\n\n script: async (bot) => {\n await bot.wait(200);\n await bot.room.call.cheer({ volume: 3 });\n await bot.wait(500);\n },\n\n assert: (timeline) => {\n timeline.check('nobody left the arena', () => {\n for (const tick of timeline.ticks) {\n for (const id of timeline.at(tick).players!.ids()) {\n const p = timeline.at(tick).players!.get(id)!;\n if (p.x < 0 || p.x > 800) throw new Error(`player ${id} at x=${p.x} on tick ${tick}`);\n }\n }\n });\n },\n});\n```\n\n`cheat: true` makes the bots write play-illegal values. If the run reports\n`HOLE bot <n> cheated and drew 0 corrections`, your `validate` accepted them, and that is the\nfinding. `truth: true` additionally saves the room at the end and diffs it against what each client\nactually received.\n\nTwo things to know before you read a result.\n\n**A cheat run that works fails the run by default.** Every refused write is a correction, and the\nbuilt-in `correction-storm` invariant fails above 5 corrections per second per bot. A deliberate\ncheater passes that immediately. Raise it for the run with `--corrections-max 25`, and read the\n`HOLE` line rather than the exit code as the verdict on `validate`.\n\n**The built-in invariants alone prove nothing about `validate`.** They are protocol invariants:\nframes decoded, nobody saw what they should not, bandwidth inside budget, no tick overruns. A room\nthat accepts every illegal write there is passes all of them. Only `cheat` and your own scenario\nassertions test server authority, so a run with neither says nothing about whether your game can be\ncheated.\n\n## One retrofit diff\n\nThe canvas case, the shortest one. The cursors canvas example is this diff as running code; see\n[the retrofit guide](/docs/guides/retrofit).\n\n```diff\n+import { joinRoom } from '@irtio/client';\n+\n+import { schema } from './irtio/schema.js';\n+\n+const room = await joinRoom(schema, { name: 'you' });\n-const player = { x: canvas.width / 2, y: canvas.height / 2, name: 'you', color: 200 };\n-\n canvas.addEventListener('pointermove', (event) => {\n const bounds = canvas.getBoundingClientRect();\n+ const player = room.state.players[room.me];\n+ if (!player) return;\n player.x = event.clientX - bounds.left;\n player.y = event.clientY - bounds.top;\n });\n\n function frame() {\n- const entries = [['me', player]];\n+ const entries = [...room.render.players];\n for (const [, p] of entries) draw(p);\n }\n```\n\nThe whole shape is an import, a join, an entity lookup instead of a local object, and one changed\niteration. The camera, particles, input handling, HUD, draw code and level geometry do not\nmove. If your retrofit touches those, it is doing more than a retrofit. Three.js and Phaser follow\nthe same four steps against their own loops; see the retrofit guide.\n\nYour entity does not exist for the first frame or two after the page loads, which is what\n`if (!player) return;` is for. Writing an instance you do not own is a compile error and a\nwarn-once no-op at runtime.\n\n## When it goes wrong\n\nThe codes you will actually meet, with the fix. The full catalogue is at `/docs/reference/errors`.\n\n| Code | Fix |\n|---|---|\n| `E_AUTH` | The project key was rejected. Check `project` in `irtio/schema.ts` matches the project you are deploying to. On localhost with no `irtio init` the client uses the key `dev`, which only `irtio dev` accepts |\n| `E_SCHEMA_MISMATCH` | The client was built with a different schema than the room is running. The hash covers every field, type, order, role and RPC signature. Rebuild the client, or deploy the schema the client has |\n| `E_ORIGIN` | The page's origin is not in the project's origin list. Register it. `localhost` is always allowed, so this only bites on a deployed page |\n| `E_ROOM_FULL` | The room is at `maxClients`, 64 by default. Raise it in `irtio/room.ts`, or join with no `?room=` to get a new room |\n| `E_ROOM_NOT_FOUND` | No such room, or an id that cannot be one. Do not construct ids yourself: let `joinRoom` create one and read `room.link` |\n| `E_WRITE_REJECTED` | Your `validate` refused the write, or the value did not fit its declared type. If this is a legitimate move, `validate` is too strict or the field too narrow |\n| `E_NOT_OWNER` | Something wrote an instance it does not own. `await room.requestOwnership(entity, id)` first, and re-read the instance after a grant |\n| `E_RPC_BAD_PARAMS` | Parameters did not match the declared shape. Types normally prevent this, so it means client and server were built from different `rpc.ts` files |\n| `E_RATE_LIMITED` | Read the message. `rate limited` is too many frames: batch instead of calling `room.flush()` in a loop. `too many connections from this address` is the per-IP cap (`connectionsPerIpPerMin`, default 120), which a load run from one machine has to raise |\n| `E_STARTING` | Not an error. The server was asleep and is waking. `room.status === 'starting'`; show a spinner and keep waiting |\n| `E_SLOW_CONSUMER` | The client was not draining its stream and was dropped; it reconnects itself. If it recurs, the room produces more per tick than the connection carries: lower `tickRate`, narrow field types, or use role visibility |\n| `E_CONNECT_FAILED` | Not a protocol code. The socket failed before the join. Read the URL in the message: `ws://localhost:7070` means `irtio dev` is not running |\n| `deploy refused: N breaking changes` | Snapshots cannot be read under the new schema. `irtio migrate create <name>`, write the transform, then `irtio deploy --allow-breaking`. Additive changes (a new field with `.default(...)` or `.opt`) need none of this |\n| `not logged in` | `deploy`, `logs`, `rooms`, `whoami` and `migrate create` need credentials. Run `irtio login` |\n| `HOLE bot <n> cheated and drew 0 corrections` | Not a failure, and the most important warning here. Illegal writes were accepted. Add rules to `validate` |\n\n## Past the skeleton\n\nThe skeleton above is the smallest thing that deploys. Each of these is one docs page\nand none of them changes the model.\n\n- Server-authoritative physics with client prediction: `irtio init --physics`, a shared\n `irtio/world.ts` both sides import. Three engines: Rapier in 3D (`/docs/physics/overview`),\n Rapier in 2D (`/docs/physics/rapier2d`), or matter.js in 2D (`/docs/physics/matter2d`,\n `tickRate: 60`). Recommend Rapier for a new game and reach for matter2d only for a room already\n on it. All three predict, and `room.prediction` reads the same either way\n (`/docs/physics/prediction`).\n- Scripted NPCs that are ordinary client sessions at the protocol level: `room.spawnNPC(...)`\n (`/docs/guides/npcs`).\n- Player storage that outlives a room (`room.kv`), room saves and restores, hibernation, and the\n `retention` option that sets how long a room's state survives\n (`/docs/persistence/player-storage`, `/docs/persistence/saves`, `/docs/concepts/hibernation`).\n- Signed player tokens, roles, and per-role visibility (`/docs/concepts/auth`,\n `/docs/concepts/visibility`).\n- A player identity that survives reconnects, hibernation and a closed tab\n (`/docs/concepts/identity`).\n- Ranked boards per project, written only by your room code\n (`/docs/persistence/leaderboards`).\n- Two strangers matched into a room without either knowing a room code\n (`/docs/concepts/quick-match`).\n- Voice chat in a room, on its own meter (`/docs/concepts/voice`).\n- One of your rooms sending a message to another (`/docs/concepts/room-bus`).\n- A project split across more than one server when a single one is full\n (`/docs/deploy/sharding`).\n\n**Starting from an engine.** Build against the engine's own loop from the first line: React and\nreact-three-fiber (`/docs/integrations/react`), Three.js (`/docs/integrations/threejs`), Phaser\n(`/docs/integrations/phaser`), PixiJS (`/docs/integrations/pixijs`), Babylon.js\n(`/docs/integrations/babylonjs`).\n\n**Moving a game that already exists.** Single-player, which is the diff above at full length:\ncanvas (`/docs/guides/retrofit`), Three.js (`/docs/guides/retrofit-threejs`), Phaser\n(`/docs/guides/retrofit-phaser`). Already multiplayer on something else: Colyseus\n(`/docs/migrate/colyseus`), Playroom (`/docs/migrate/playroom`), Socket.IO\n(`/docs/migrate/socket-io`).\n\nEvery term these pages use is defined once in the glossary (`/docs/reference/glossary`).\n\n## Rules of thumb\n\n- Sync the smallest set of facts two players must agree on. Everything else stays local.\n- A player reporting a fact about themselves is an owned write. Anything a player could gain by\n lying about (scores, deals, turn order, hit detection) is `serverOwned` plus an RPC.\n- Types are budgets, not hints. `f32`, `u8`, `str(24)` are what make an update a handful of bytes.\n- Never import `room.ts` from client code. Shared geometry and constants go in their own module\n that both sides import.\n- Do not paste a credential into a config file. `irtio login` writes one; MCP and the CLI find it.\n- Believe a room when a scenario with an adversarial pass says so, not when it looks right in two\n tabs.\n";
|
|
18
|
+
var AGENT_GUIDE = "# irt.io integration guide\n\nThe whole product in one page, written for a context window. Everything below is enough to take a\nsingle-player web game to a deployed, verified multiplayer room without opening a dashboard.\n\n## The mental model\n\nA **room** is a server-side TypeScript file. It owns a small typed **schema**: the state two\nplayers have to agree on, and nothing else about your game. Each client owns its own instances and\nwrites them like local objects. The room's `validate` decides what a write may be. Everything else\nin your game stays where it was. You verify a room the way you verify code. Bots drive real\nsockets, a **scenario** asserts against the server's own recorded timeline, and the same seed gives\nthe same verdict.\n\n## The skeleton\n\nThe smallest project that deploys. Four files, and `irtio init` writes all of them.\n\n```ts\n// irtio/schema.ts\nimport { defineSchema, entity, f32, str, u8 } from '@irtio/schema';\n\nimport { rpc } from './rpc.js';\n\nexport const schema = defineSchema(\n {\n // One instance per connected client, owned by that client. Owned means writable on that\n // client and read-only everywhere else.\n players: entity({ x: f32, y: f32, name: str(24), color: u8 }),\n },\n {\n project: 'p_c0ffee1234abcd56', // written by `irtio init`; public, domain-locked, not a secret\n roles: ['player'] as const,\n rpc,\n },\n);\n```\n\n```ts\n// irtio/rpc.ts\nimport { server, u8 } from '@irtio/schema';\n\n// Typed calls in both directions. `server(...)` is client to server. Names and signatures are\n// part of the schema hash, so skew is caught as a version error instead of at runtime.\nexport const rpc = {\n cheer: server({ params: { volume: u8 } }),\n};\n```\n\n```ts\n// irtio/room.ts\nimport { defineRoom } from '@irtio/server';\n\nimport { schema } from './schema.js';\n\nconst WIDTH = 800;\nconst HEIGHT = 500;\n\nexport default defineRoom(schema, {\n mode: 'tick', // 'event' if nothing moves without a player doing something\n tickRate: 20,\n\n onJoin(state, ctx) {\n if (ctx.reconnecting) return;\n state.players.add(\n ctx.clientId,\n { x: 0, y: 0, name: ctx.name || 'anon', color: (ctx.tick * 37) % 256 },\n { owner: ctx.clientId }, // the line the whole client-side write model rests on\n );\n },\n\n onLeave(state, ctx) {\n state.players.remove(ctx.clientId);\n },\n\n // Owner writes pass through here before they are accepted. Return `next` to accept, `prev` to\n // reject, or a clamped object. This is where cheating stops. Delete it and anything that fits\n // the declared types is accepted.\n validate: {\n players(prev, next) {\n // Refuse what could never be a real value, lock the fields the room assigned at join,\n // and clamp the rest into the world.\n if (!Number.isFinite(next.x) || !Number.isFinite(next.y)) return prev;\n if (next.name !== prev.name || next.color !== prev.color) return prev;\n return {\n ...next,\n x: Math.min(WIDTH, Math.max(0, next.x)),\n y: Math.min(HEIGHT, Math.max(0, next.y)),\n };\n },\n },\n\n rpc: {\n cheer(state, params, ctx) {\n if (params.volume > 10) throw new Error('too loud'); // the normal way a room says no\n state.players.get(ctx.clientId)!.color = params.volume;\n },\n },\n\n tick() {}, // tick mode needs one, even empty\n});\n```\n\n```ts\n// your game, three lines added\nimport { joinRoom } from '@irtio/client';\n\nimport { schema } from './irtio/schema.js';\n\nconst room = await joinRoom(schema, { name: 'you' }); // reads ?room=, or creates one\nconst me = room.state.players[room.me]; // yours: write it like a local object\nif (me) me.x = 10;\n\nfor (const [, p] of room.render.players) draw(p); // `render` interpolates everyone else\n```\n\n`room.state` is the authoritative read path for game logic and tests. `room.render` has the same\nshapes and reads non-owned entities a beat behind arrival, interpolated, which is what you draw\nfrom. Writes are batched once per animation frame, and `room.flush()` forces one out now.\n`room.leave()` closes the session. A page rarely needs it; a test with several sessions always\ndoes. `joinRoom`'s options are `{ name, role, url, key, token, physics, transport }`; see\nthe client reference.\n\n`@irtio/client` is a module, so the page that imports it needs whatever bundler your game already\nuses. There is no `irtio build`.\n\n### Write the validate rule your game actually has\n\n`validate` is the one part of the skeleton you cannot copy without thinking. The wrong rule here\nis worse than no rule, because it refuses honest play silently. The server sends a correction, and\nthe thing does not go where the player put it.\n\nA **speed limit** (`if (Math.hypot(next.x - prev.x, next.y - prev.y) > MAX_STEP) return prev;`) is\nright when the thing being moved has a movement speed the game defines: a character, a vehicle, a\ndragged token. It is wrong for a **mouse cursor**. A cursor legitimately teleports when the pointer moves fast,\nor when it leaves the window and comes back. A player gains nothing by putting their cursor\nsomewhere.\n\nAsk what a cheat would actually win. Position, for a cursor, wins nothing, so clamp it and stop.\nPosition for a character wins map knowledge and reach, so limit the step. Score, turn order, hit\ndetection and inventory win the game outright, so they should not be client-owned at all: make\nthem `serverOwned` and change them through an RPC.\n\n## The four commands\n\n```bash\nnpx --package @irtio/cli irtio init # scaffold the four files above plus irtio.json\nnpx --package @irtio/cli irtio dev # bundle irtio/room.ts and run it locally\nnpx --package @irtio/cli irtio simulate --scenario irtio/scenario.ts # real bots, your assertions, exit code\nnpx --package @irtio/cli irtio deploy # create the project if new, classify, upload\n```\n\n`simulate` drives real clients at a room that is **already running**, so `dev` stays up in another\nterminal while you run it. The four are a loop, not a pipeline.\n\nEvery subcommand takes `--help`, and the per-command help is the complete list; the top-level\nsummary is a map.\n\n`dev` is the whole server, locally: it bundles the room file and runs it the way the hosted\nserver does, so it is what you develop and test against. It does not build or serve your\n**client**. That stays your own bundler's job, the same one your game already uses.\n\n`deploy` is the hosted path and needs `irtio login`. There is no offline `deploy`. Without an\naccount, `dev` plus your own bundler is as far as you get, and that runs everything below.\n\n`deploy` creates the project on first use and classifies the schema change against the last\ndeployment. It refuses breaking changes unless a migration covers them, then prints where the room\nis playable. `simulate` exits non-zero on a violation, so both belong in CI.\n\nWhen the question is \"why is this room using so much bandwidth\", both commands take `--profile`.\n`dev --profile` prints a per-room breakdown by collection and field once a second.\n`simulate --profile` prints one for the whole run as the bots saw it. The answer is usually a\nsingle wide field, or `overhead` telling you the updates are too small and too frequent. See\n[the profiler](/docs/guides/profiler).\n\n**Restart `dev` after editing the room.** With `--no-watch`, a `dev` you forgot to kill keeps\nserving the old bundle. `simulate` connects to it and reports a clean run against code you have\nreplaced. The report header names the bundle hash and how long the server has been up. Compare\nthose two lines between runs: an edit that changed nothing in the header changed nothing in the\ncode under test.\n\nAn agent driving this through MCP calls `project_create`, `origin_add`, `deploy`, `scenario_run`,\nthen `logs` or `metrics`. Sign in once with `npx --package @irtio/cli irtio login`; the MCP config snippet is a command\nand carries no secret.\n\n## Verify a room with a scenario\n\nA scenario is a TypeScript module next to the room file. Assertions run against the recorded\nauthoritative timeline, one frame per server tick. A scenario can prove something about a race no\nclient could observe, and it replays: same seed, same verdict.\n\n```ts\n// irtio/scenario.ts\nimport { defineScenario } from '@irtio/bots';\n\nimport type { schema } from './schema.js';\n\nexport default defineScenario<typeof schema>({\n bots: 2,\n seconds: 4,\n seed: 41,\n\n // Adversarial in two words: bot 0 lags, and every bot tries illegal writes. `validate` above\n // is what has to refuse them.\n conditions: (index) => (index === 0 ? { rttMs: 200 } : undefined),\n cheat: true,\n\n script: async (bot) => {\n await bot.wait(200);\n await bot.room.call.cheer({ volume: 3 });\n await bot.wait(500);\n },\n\n assert: (timeline) => {\n timeline.check('nobody left the arena', () => {\n for (const tick of timeline.ticks) {\n for (const id of timeline.at(tick).players!.ids()) {\n const p = timeline.at(tick).players!.get(id)!;\n if (p.x < 0 || p.x > 800) throw new Error(`player ${id} at x=${p.x} on tick ${tick}`);\n }\n }\n });\n },\n});\n```\n\n`cheat: true` makes the bots write play-illegal values. If the run reports\n`HOLE bot <n> cheated and drew 0 corrections`, your `validate` accepted them, and that is the\nfinding. `truth: true` additionally saves the room at the end and diffs it against what each client\nactually received.\n\nTwo things to know before you read a result.\n\n**A cheat run that works fails the run by default.** Every refused write is a correction, and the\nbuilt-in `correction-storm` invariant fails above 5 corrections per second per bot. A deliberate\ncheater passes that immediately. Raise it for the run with `--corrections-max 25`, and read the\n`HOLE` line rather than the exit code as the verdict on `validate`.\n\n**The built-in invariants alone prove nothing about `validate`.** They are protocol invariants:\nframes decoded, nobody saw what they should not, bandwidth inside budget, no tick overruns. A room\nthat accepts every illegal write there is passes all of them. Only `cheat` and your own scenario\nassertions test server authority, so a run with neither says nothing about whether your game can be\ncheated.\n\n## One retrofit diff\n\nThe canvas case, the shortest one. The cursors canvas example is this diff as running code; see\n[the retrofit guide](/docs/guides/retrofit).\n\n```diff\n+import { joinRoom } from '@irtio/client';\n+\n+import { schema } from './irtio/schema.js';\n+\n+const room = await joinRoom(schema, { name: 'you' });\n-const player = { x: canvas.width / 2, y: canvas.height / 2, name: 'you', color: 200 };\n-\n canvas.addEventListener('pointermove', (event) => {\n const bounds = canvas.getBoundingClientRect();\n+ const player = room.state.players[room.me];\n+ if (!player) return;\n player.x = event.clientX - bounds.left;\n player.y = event.clientY - bounds.top;\n });\n\n function frame() {\n- const entries = [['me', player]];\n+ const entries = [...room.render.players];\n for (const [, p] of entries) draw(p);\n }\n```\n\nThe whole shape is an import, a join, an entity lookup instead of a local object, and one changed\niteration. The camera, particles, input handling, HUD, draw code and level geometry do not\nmove. If your retrofit touches those, it is doing more than a retrofit. Three.js and Phaser follow\nthe same four steps against their own loops; see the retrofit guide.\n\nYour entity does not exist for the first frame or two after the page loads, which is what\n`if (!player) return;` is for. Writing an instance you do not own is a compile error and a\nwarn-once no-op at runtime.\n\n## When it goes wrong\n\nThe codes you will actually meet, with the fix. The full catalogue is at `/docs/reference/errors`.\n\n| Code | Fix |\n|---|---|\n| `E_AUTH` | The project key was rejected. Check `project` in `irtio/schema.ts` matches the project you are deploying to. On localhost with no `irtio init` the client uses the key `dev`, which only `irtio dev` accepts |\n| `E_SCHEMA_MISMATCH` | The client was built with a different schema than the room is running. The hash covers every field, type, order, role and RPC signature. Rebuild the client, or deploy the schema the client has |\n| `E_ORIGIN` | The page's origin is not in the project's origin list. Register it. `localhost` is always allowed, so this only bites on a deployed page |\n| `E_ROOM_FULL` | The room is at `maxClients`, 64 by default. Raise it in `irtio/room.ts`, or join with no `?room=` to get a new room |\n| `E_ROOM_NOT_FOUND` | No such room, or an id that cannot be one. Do not construct ids yourself: let `joinRoom` create one and read `room.link` |\n| `E_WRITE_REJECTED` | Your `validate` refused the write, or the value did not fit its declared type. If this is a legitimate move, `validate` is too strict or the field too narrow |\n| `E_NOT_OWNER` | Something wrote an instance it does not own. `await room.requestOwnership(entity, id)` first, and re-read the instance after a grant |\n| `E_RPC_BAD_PARAMS` | Parameters did not match the declared shape. Types normally prevent this, so it means client and server were built from different `rpc.ts` files |\n| `E_RATE_LIMITED` | Read the message. `rate limited` is too many frames: batch instead of calling `room.flush()` in a loop. `too many connections from this address` is the per-IP cap (`connectionsPerIpPerMin`, default 120), which a load run from one machine has to raise |\n| `E_STARTING` | Not an error. The server was asleep and is waking. `room.status === 'starting'`; show a spinner and keep waiting |\n| `E_SLOW_CONSUMER` | The client was not draining its stream and was dropped; it reconnects itself. If it recurs, the room produces more per tick than the connection carries: lower `tickRate`, narrow field types, or use role visibility |\n| `E_CONNECT_FAILED` | Not a protocol code. The socket failed before the join. Read the URL in the message: `ws://localhost:7070` means `irtio dev` is not running |\n| `deploy refused: N breaking changes` | Snapshots cannot be read under the new schema. `irtio migrate create <name>`, write the transform, then `irtio deploy --allow-breaking`. Additive changes (a new field with `.default(...)` or `.opt`) need none of this |\n| `not logged in` | `deploy`, `logs`, `rooms`, `whoami` and `migrate create` need credentials. Run `irtio login` |\n| `HOLE bot <n> cheated and drew 0 corrections` | Not a failure, and the most important warning here. Illegal writes were accepted. Add rules to `validate` |\n\n## Past the skeleton\n\nThe skeleton above is the smallest thing that deploys. Each of these is one docs page\nand none of them changes the model.\n\n- Server-authoritative physics with client prediction: `irtio init --physics`, a shared\n `irtio/world.ts` both sides import. Three engines: Rapier in 3D (`/docs/physics/overview`),\n Rapier in 2D (`/docs/physics/rapier2d`), or matter.js in 2D (`/docs/physics/matter2d`,\n `tickRate: 60`). Recommend Rapier for a new game and reach for matter2d only for a room already\n on it. All three predict, and `room.prediction` reads the same either way\n (`/docs/physics/prediction`).\n- Scripted NPCs that are ordinary client sessions at the protocol level: `room.spawnNPC(...)`\n (`/docs/guides/npcs`).\n- Player storage that outlives a room (`room.kv`), room saves and restores, hibernation, and the\n `retention` option that sets how long a room's state survives\n (`/docs/persistence/player-storage`, `/docs/persistence/saves`, `/docs/concepts/hibernation`).\n- Signed player tokens, roles, and per-role visibility (`/docs/concepts/auth`,\n `/docs/concepts/visibility`).\n- A player identity that survives reconnects, hibernation and a closed tab\n (`/docs/concepts/identity`).\n- Ranked boards per project, written only by your room code\n (`/docs/persistence/leaderboards`).\n- Two strangers matched into a room without either knowing a room code\n (`/docs/concepts/quick-match`).\n- Voice chat in a room, on its own meter (`/docs/concepts/voice`).\n- One of your rooms sending a message to another (`/docs/concepts/messages`).\n- A project split across more than one server when a single one is full\n (`/docs/deploy/sharding`).\n\n**Starting from an engine.** Build against the engine's own loop from the first line: React and\nreact-three-fiber (`/docs/integrations/react`), Three.js (`/docs/integrations/threejs`), Phaser\n(`/docs/integrations/phaser`), PixiJS (`/docs/integrations/pixijs`), Babylon.js\n(`/docs/integrations/babylonjs`).\n\n**Moving a game that already exists.** Single-player, which is the diff above at full length:\ncanvas, Three.js and Phaser are all covered in `/docs/guides/retrofit`. Already multiplayer on\nsomething else: Colyseus\n(`/docs/migrate/colyseus`), Playroom (`/docs/migrate/playroom`), Socket.IO\n(`/docs/migrate/socket-io`).\n\nEvery term these pages use is defined once in the glossary (`/docs/reference/glossary`).\n\n## Rules of thumb\n\n- Sync the smallest set of facts two players must agree on. Everything else stays local.\n- A player reporting a fact about themselves is an owned write. Anything a player could gain by\n lying about (scores, deals, turn order, hit detection) is `serverOwned` plus an RPC.\n- Types are budgets, not hints. `f32`, `u8`, `str(24)` are what make an update a handful of bytes.\n- Never import `room.ts` from client code. Shared geometry and constants go in their own module\n that both sides import.\n- Do not paste a credential into a config file. `irtio login` writes one; MCP and the CLI find it.\n- Believe a room when a scenario with an adversarial pass says so, not when it looks right in two\n tabs.\n";
|
|
19
19
|
|
|
20
20
|
// src/init.ts
|
|
21
21
|
var INIT_DEPENDENCIES = [
|
|
@@ -270,9 +270,13 @@ export const bodies = {
|
|
|
270
270
|
}),
|
|
271
271
|
};
|
|
272
272
|
|
|
273
|
-
/**
|
|
273
|
+
/**
|
|
274
|
+
* Intent \u2192 force, once per step. The room's tick() and the client's predictor both call this,
|
|
275
|
+
* with the same four arguments. \`rapier\` and \`world\` are there for world queries \u2014 a ground
|
|
276
|
+
* check via \`world.castRay(new rapier.Ray(...), ...)\`, contact queries, and the like.
|
|
277
|
+
*/
|
|
274
278
|
export const intents = {
|
|
275
|
-
players: (body: Body, player: { ax: number; az: number }): void => {
|
|
279
|
+
players: (body: Body, player: { ax: number; az: number }, _rapier: Rapier, _world: World): void => {
|
|
276
280
|
if (player.ax === 0 && player.az === 0) return;
|
|
277
281
|
body.applyImpulse({ x: player.ax * NUDGE, y: 0, z: player.az * NUDGE }, true);
|
|
278
282
|
},
|
|
@@ -339,9 +343,11 @@ export default defineRoom(schema, {
|
|
|
339
343
|
},
|
|
340
344
|
|
|
341
345
|
tick(state, _dt, room) {
|
|
346
|
+
// Same four arguments the client's predictor passes, so shared hooks can query the world.
|
|
347
|
+
const { rapier, world } = room.physics;
|
|
342
348
|
for (const [id, player] of state.players) {
|
|
343
349
|
const body = room.physics.body('players', id);
|
|
344
|
-
if (body) intents.players(body, player);
|
|
350
|
+
if (body) intents.players(body, player, rapier, world);
|
|
345
351
|
}
|
|
346
352
|
},
|
|
347
353
|
});
|
|
@@ -422,7 +428,7 @@ function nextSteps(installCommand) {
|
|
|
422
428
|
pc.cyan(` ${installCommand}`),
|
|
423
429
|
"",
|
|
424
430
|
" 2. start the room",
|
|
425
|
-
pc.cyan(" npx irtio dev"),
|
|
431
|
+
pc.cyan(" npx --package @irtio/cli irtio dev"),
|
|
426
432
|
" it prints a ws:// URL and an inspector page.",
|
|
427
433
|
"",
|
|
428
434
|
" 3. join from your game \u2014 three lines:",
|
|
@@ -443,7 +449,7 @@ function nextSteps(installCommand) {
|
|
|
443
449
|
" shows the code, the link and a QR for you.)",
|
|
444
450
|
"",
|
|
445
451
|
" 5. check it under load before you believe it",
|
|
446
|
-
pc.cyan(" npx irtio simulate --bots 5 --seconds 10"),
|
|
452
|
+
pc.cyan(" npx --package @irtio/cli irtio simulate --bots 5 --seconds 10"),
|
|
447
453
|
"",
|
|
448
454
|
" irtio/AGENT.md is the whole product on one page: the model, this skeleton, the four",
|
|
449
455
|
" commands, a retrofit diff and every error worth knowing. Point your coding agent at it.",
|
|
@@ -4,8 +4,8 @@ import {
|
|
|
4
4
|
runStaticDeploy,
|
|
5
5
|
staticDeploy,
|
|
6
6
|
walkStaticDir
|
|
7
|
-
} from "./chunk-
|
|
8
|
-
import "./chunk-
|
|
7
|
+
} from "./chunk-VZDBLJQ2.js";
|
|
8
|
+
import "./chunk-YJB5GQHT.js";
|
|
9
9
|
import "./chunk-JL235KIE.js";
|
|
10
10
|
import "./chunk-OCVALOGK.js";
|
|
11
11
|
import "./chunk-IDF46P7R.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@irtio/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "irtio CLI: local dev server, room scaffolding, bot simulation, deploy, logs and rooms",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -45,12 +45,12 @@
|
|
|
45
45
|
"esbuild": "^0.27.3",
|
|
46
46
|
"picocolors": "^1.1.1",
|
|
47
47
|
"ws": "^8.18.0",
|
|
48
|
-
"@irtio/bots": "0.
|
|
49
|
-
"@irtio/client": "0.
|
|
50
|
-
"@irtio/
|
|
51
|
-
"@irtio/
|
|
52
|
-
"@irtio/
|
|
53
|
-
"@irtio/
|
|
48
|
+
"@irtio/bots": "0.10.0",
|
|
49
|
+
"@irtio/client": "0.10.0",
|
|
50
|
+
"@irtio/protocol": "0.10.0",
|
|
51
|
+
"@irtio/runtime": "0.10.0",
|
|
52
|
+
"@irtio/schema": "0.10.0",
|
|
53
|
+
"@irtio/server": "0.10.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/ws": "^8.5.13",
|