@irtio/cli 0.1.0 → 0.2.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
package/dist/bundle.d.ts
CHANGED
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/** Bare imports room code may use. Everything else (including node:*) is rejected. */
|
|
7
7
|
declare const ALLOWED_IMPORTS: string[];
|
|
8
|
+
/**
|
|
9
|
+
* D22: the one blessed engine. Unlike the irtio packages it is **not** inlined into the room
|
|
10
|
+
* bundle — the tenant's runtime already loaded and `init()`ed it, and a second bundled copy would
|
|
11
|
+
* be a second WASM instance with its own worlds. Left external, room code's
|
|
12
|
+
* `import RAPIER from '@dimforge/rapier3d-compat'` resolves at load time to the very module the
|
|
13
|
+
* runtime initialized.
|
|
14
|
+
*/
|
|
15
|
+
declare const EXTERNAL_IMPORTS: readonly string[];
|
|
8
16
|
interface BundleOptions {
|
|
9
17
|
/** The room entry file (must `export default defineRoom(...)`). */
|
|
10
18
|
readonly entry: string;
|
|
@@ -33,4 +41,4 @@ declare class BundleError extends Error {
|
|
|
33
41
|
/** Bundles the room entry; throws `BundleError` with the esbuild message on failure. */
|
|
34
42
|
declare function bundleRoom(options: BundleOptions): Promise<BundleResult>;
|
|
35
43
|
|
|
36
|
-
export { ALLOWED_IMPORTS, BundleError, type BundleOptions, type BundleResult, bundleRoom };
|
|
44
|
+
export { ALLOWED_IMPORTS, BundleError, type BundleOptions, type BundleResult, EXTERNAL_IMPORTS, bundleRoom };
|
package/dist/bundle.js
CHANGED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// ../store/dist/index.js
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from "fs/promises";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
var KV_LIMITS = {
|
|
5
|
+
/** Max UTF-8 bytes in one value. */
|
|
6
|
+
valueBytes: 16 * 1024,
|
|
7
|
+
/** Max UTF-8 bytes in one key. */
|
|
8
|
+
keyBytes: 256,
|
|
9
|
+
/** Max UTF-8 bytes in one player id. */
|
|
10
|
+
playerIdBytes: 256,
|
|
11
|
+
/** Max distinct keys one player may hold within one project. */
|
|
12
|
+
keysPerPlayer: 128,
|
|
13
|
+
/** Max rows one project may hold across all its players. */
|
|
14
|
+
rowsPerProject: 1e6
|
|
15
|
+
};
|
|
16
|
+
var PLAYER_ISSUER_RE = /^[a-z0-9._-]{1,64}$/;
|
|
17
|
+
var KV_ERRORS = {
|
|
18
|
+
badKey: "E_KV_BAD_KEY",
|
|
19
|
+
badPlayer: "E_KV_BAD_PLAYER",
|
|
20
|
+
valueTooLarge: "E_KV_VALUE_TOO_LARGE",
|
|
21
|
+
tooManyKeys: "E_KV_TOO_MANY_KEYS",
|
|
22
|
+
projectFull: "E_KV_PROJECT_FULL",
|
|
23
|
+
forbidden: "E_KV_FORBIDDEN",
|
|
24
|
+
unavailable: "E_KV_UNAVAILABLE"
|
|
25
|
+
};
|
|
26
|
+
var SAVES_SEGMENT = "/saves/";
|
|
27
|
+
var SAVE_ID_TIME_DIGITS = 17;
|
|
28
|
+
var DEFAULT_SAVE_RETAIN = 10;
|
|
29
|
+
var PRE_MIGRATION_SAVE_ID = "premigrate";
|
|
30
|
+
function mintSaveId(nowMs, rand) {
|
|
31
|
+
const t = Math.max(0, Math.floor(nowMs));
|
|
32
|
+
const suffix = (Math.floor(Math.abs(rand)) & 65535).toString(16).padStart(4, "0");
|
|
33
|
+
return `${String(t).padStart(SAVE_ID_TIME_DIGITS, "0")}-${suffix}`;
|
|
34
|
+
}
|
|
35
|
+
function saveIdCreatedAt(saveId) {
|
|
36
|
+
if (saveId.length !== SAVE_ID_TIME_DIGITS + 5) return void 0;
|
|
37
|
+
if (saveId[SAVE_ID_TIME_DIGITS] !== "-") return void 0;
|
|
38
|
+
const time = saveId.slice(0, SAVE_ID_TIME_DIGITS);
|
|
39
|
+
if (!/^[0-9]+$/.test(time)) return void 0;
|
|
40
|
+
if (!/^[0-9a-f]{4}$/.test(saveId.slice(SAVE_ID_TIME_DIGITS + 1))) return void 0;
|
|
41
|
+
return Number(time);
|
|
42
|
+
}
|
|
43
|
+
function savesPrefix(liveKey) {
|
|
44
|
+
return `${liveKey}${SAVES_SEGMENT}`;
|
|
45
|
+
}
|
|
46
|
+
function saveKey(liveKey, saveId) {
|
|
47
|
+
return `${savesPrefix(liveKey)}${saveId}`;
|
|
48
|
+
}
|
|
49
|
+
function saveIdOf(liveKey, key) {
|
|
50
|
+
const prefix = savesPrefix(liveKey);
|
|
51
|
+
if (!key.startsWith(prefix)) return void 0;
|
|
52
|
+
const rest = key.slice(prefix.length);
|
|
53
|
+
if (rest === "" || rest.includes("/")) return void 0;
|
|
54
|
+
return rest;
|
|
55
|
+
}
|
|
56
|
+
async function listSaves(store, liveKey) {
|
|
57
|
+
const keys = await store.list(savesPrefix(liveKey));
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const key of keys) {
|
|
60
|
+
const saveId = saveIdOf(liveKey, key);
|
|
61
|
+
if (saveId === void 0) continue;
|
|
62
|
+
const createdAt = saveIdCreatedAt(saveId);
|
|
63
|
+
if (createdAt === void 0) continue;
|
|
64
|
+
out.push({ saveId, key, createdAt });
|
|
65
|
+
}
|
|
66
|
+
out.sort((a, b) => a.saveId < b.saveId ? 1 : a.saveId > b.saveId ? -1 : 0);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
function selectForPruning(saves, retain) {
|
|
70
|
+
const keep = Number.isFinite(retain) ? Math.max(1, Math.floor(retain)) : 1;
|
|
71
|
+
return saves.length <= keep ? [] : saves.slice(keep);
|
|
72
|
+
}
|
|
73
|
+
async function pruneSaves(store, saves, retain, log) {
|
|
74
|
+
const doomed = selectForPruning(saves, retain);
|
|
75
|
+
let deleted = 0;
|
|
76
|
+
for (const save of doomed) {
|
|
77
|
+
try {
|
|
78
|
+
await store.delete(save.key);
|
|
79
|
+
deleted++;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
log("warn", `save retention: could not delete ${save.key}`, err);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return deleted;
|
|
85
|
+
}
|
|
86
|
+
var STATIC_LIMITS = {
|
|
87
|
+
/** Bytes per file. Big enough for a wasm build or a texture atlas; a video does not belong. */
|
|
88
|
+
maxFileBytes: 32 * 1024 * 1024,
|
|
89
|
+
/** Files per deploy. */
|
|
90
|
+
maxFiles: 2e3,
|
|
91
|
+
/** Total bytes per deploy. */
|
|
92
|
+
maxTotalBytes: 256 * 1024 * 1024
|
|
93
|
+
};
|
|
94
|
+
function staticPathProblem(path2) {
|
|
95
|
+
if (path2.length === 0 || path2.length > 512) return "path must be 1-512 characters";
|
|
96
|
+
if (path2.startsWith("/")) return "path must be relative (no leading slash)";
|
|
97
|
+
if (path2.includes("\\")) return "path must use forward slashes";
|
|
98
|
+
if (/[\x00-\x1f\x7f]/.test(path2)) return "path must not contain control characters";
|
|
99
|
+
for (const segment of path2.split("/")) {
|
|
100
|
+
if (segment === "" || segment === "." || segment === "..") {
|
|
101
|
+
return "path segments must be non-empty and not . or ..";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return void 0;
|
|
105
|
+
}
|
|
106
|
+
var DiskStore = class {
|
|
107
|
+
constructor(dir) {
|
|
108
|
+
this.dir = dir;
|
|
109
|
+
}
|
|
110
|
+
dir;
|
|
111
|
+
fileFor(key) {
|
|
112
|
+
if (!/^[A-Za-z0-9_.@/-]+$/.test(key) || key.includes("..")) {
|
|
113
|
+
throw new Error(`invalid object store key ${JSON.stringify(key)}`);
|
|
114
|
+
}
|
|
115
|
+
return path.join(this.dir, `${key}.snap`);
|
|
116
|
+
}
|
|
117
|
+
async put(key, bytes) {
|
|
118
|
+
const file = this.fileFor(key);
|
|
119
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
120
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
121
|
+
await writeFile(tmp, bytes);
|
|
122
|
+
for (let attempt = 0; ; attempt++) {
|
|
123
|
+
try {
|
|
124
|
+
await rename(tmp, file);
|
|
125
|
+
return;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const code = err.code;
|
|
128
|
+
if ((code === "EPERM" || code === "EBUSY") && attempt < 10) {
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, 5 * (attempt + 1)));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async get(key) {
|
|
137
|
+
try {
|
|
138
|
+
return new Uint8Array(await readFile(this.fileFor(key)));
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err.code === "ENOENT") return void 0;
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async delete(key) {
|
|
145
|
+
await rm(this.fileFor(key), { force: true });
|
|
146
|
+
}
|
|
147
|
+
async list(prefix) {
|
|
148
|
+
const out = [];
|
|
149
|
+
const walk = async (dir, rel) => {
|
|
150
|
+
let entries;
|
|
151
|
+
try {
|
|
152
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err.code === "ENOENT") return;
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
for (const e of entries) {
|
|
158
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
159
|
+
if (e.isDirectory()) await walk(path.join(dir, e.name), r);
|
|
160
|
+
else if (e.name.endsWith(".snap")) out.push(r.slice(0, -".snap".length));
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
await walk(this.dir, "");
|
|
164
|
+
return out.filter((k) => k.startsWith(prefix)).sort();
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export {
|
|
169
|
+
PLAYER_ISSUER_RE,
|
|
170
|
+
KV_ERRORS,
|
|
171
|
+
DEFAULT_SAVE_RETAIN,
|
|
172
|
+
PRE_MIGRATION_SAVE_ID,
|
|
173
|
+
mintSaveId,
|
|
174
|
+
saveKey,
|
|
175
|
+
listSaves,
|
|
176
|
+
pruneSaves,
|
|
177
|
+
STATIC_LIMITS,
|
|
178
|
+
staticPathProblem,
|
|
179
|
+
DiskStore
|
|
180
|
+
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// src/bundle.ts
|
|
2
|
-
import { createHash } from "crypto";
|
|
3
|
-
import { mkdir, writeFile } from "fs/promises";
|
|
2
|
+
import { createHash, randomBytes } from "crypto";
|
|
3
|
+
import { access, mkdir, rename, unlink, writeFile } from "fs/promises";
|
|
4
4
|
import * as path from "path";
|
|
5
5
|
import { pathToFileURL } from "url";
|
|
6
6
|
import { Worker } from "worker_threads";
|
|
7
7
|
import * as esbuild from "esbuild";
|
|
8
|
-
var ALLOWED_IMPORTS = ["@irtio/server", "@irtio/schema"];
|
|
8
|
+
var ALLOWED_IMPORTS = ["@irtio/server", "@irtio/schema", "@dimforge/rapier3d-compat"];
|
|
9
|
+
var EXTERNAL_IMPORTS = ["@dimforge/rapier3d-compat"];
|
|
9
10
|
var BundleError = class extends Error {
|
|
10
11
|
name = "BundleError";
|
|
11
12
|
};
|
|
@@ -17,6 +18,7 @@ function importMenuPlugin(allowed) {
|
|
|
17
18
|
if (args.kind === "entry-point" || path.isAbsolute(args.path)) return null;
|
|
18
19
|
const root = args.path.split("/")[0].startsWith("@") ? args.path.split("/").slice(0, 2).join("/") : args.path.split("/")[0];
|
|
19
20
|
if (allowed.has(root)) {
|
|
21
|
+
if (EXTERNAL_IMPORTS.includes(root)) return { path: args.path, external: true };
|
|
20
22
|
const redirect = allowed.get(root);
|
|
21
23
|
if (redirect) return { path: redirect };
|
|
22
24
|
return null;
|
|
@@ -71,6 +73,7 @@ async function bundleRoom(options) {
|
|
|
71
73
|
write: false,
|
|
72
74
|
outdir: options.outDir,
|
|
73
75
|
plugins: [importMenuPlugin(allowed)],
|
|
76
|
+
metafile: true,
|
|
74
77
|
logLevel: "silent",
|
|
75
78
|
mainFields: ["module", "main"],
|
|
76
79
|
conditions: ["import"]
|
|
@@ -82,14 +85,34 @@ async function bundleRoom(options) {
|
|
|
82
85
|
const out = result.outputFiles[0];
|
|
83
86
|
if (!out) throw new BundleError("esbuild produced no output");
|
|
84
87
|
const warnings = result.warnings.map((w) => w.text);
|
|
88
|
+
const importsRapier = Object.values(result.metafile?.outputs ?? {}).some(
|
|
89
|
+
(o) => o.imports.some((i) => i.path === "@dimforge/rapier3d-compat")
|
|
90
|
+
);
|
|
85
91
|
const hash = createHash("sha256").update(out.contents).digest("hex");
|
|
86
92
|
const file = path.join(options.outDir, `room.${hash.slice(0, 16)}.mjs`);
|
|
87
93
|
await mkdir(options.outDir, { recursive: true });
|
|
88
94
|
const banner = `// irtio room bundle ${hash}
|
|
89
95
|
`;
|
|
90
|
-
|
|
96
|
+
const tmp = path.join(
|
|
97
|
+
options.outDir,
|
|
98
|
+
`.room.${hash.slice(0, 16)}.${randomBytes(6).toString("hex")}.tmp`
|
|
99
|
+
);
|
|
100
|
+
await writeFile(tmp, banner + out.text);
|
|
101
|
+
try {
|
|
102
|
+
await rename(tmp, file);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
const alreadyThere = await access(file).then(() => true).catch(() => false);
|
|
105
|
+
await unlink(tmp).catch(() => {
|
|
106
|
+
});
|
|
107
|
+
if (!alreadyThere) throw err;
|
|
108
|
+
}
|
|
91
109
|
if (options.verify === false) return { file, hash, warnings };
|
|
92
110
|
const verified = await verifyBundle(file);
|
|
111
|
+
if (importsRapier && !verified.physics) {
|
|
112
|
+
throw new BundleError(
|
|
113
|
+
"irtio: this room imports @dimforge/rapier3d-compat but declares no physics. Add\n physics: { engine: 'rapier3d', gravity: { x: 0, y: -9.81, z: 0 }, bodies: { ... } }\nto defineRoom(...), and mark the body-backed fields with the schema's physics option. Without it the runtime never creates a world, and nothing you build with the engine steps."
|
|
114
|
+
);
|
|
115
|
+
}
|
|
93
116
|
const fullHash = createHash("sha256").update(out.contents).update(verified.canonical).digest("hex");
|
|
94
117
|
return {
|
|
95
118
|
file,
|
|
@@ -110,7 +133,7 @@ async function verifyBundle(file) {
|
|
|
110
133
|
parentPort.postMessage({ ok: false, reason:
|
|
111
134
|
'the bundle\\'s default export is not a room \u2014 end your room file with: export default defineRoom(schema, {...})' });
|
|
112
135
|
} else {
|
|
113
|
-
parentPort.postMessage({ ok: true, schemaHash: def.schema.hash, canonical: def.schema.canonical, mode: def.config.mode });
|
|
136
|
+
parentPort.postMessage({ ok: true, schemaHash: def.schema.hash, canonical: def.schema.canonical, mode: def.config.mode, physics: def.config.physics !== undefined });
|
|
114
137
|
}
|
|
115
138
|
} catch (err) {
|
|
116
139
|
parentPort.postMessage({ ok: false, reason: String(err && err.stack || err) });
|
|
@@ -133,7 +156,12 @@ async function verifyBundle(file) {
|
|
|
133
156
|
});
|
|
134
157
|
});
|
|
135
158
|
if (!msg.ok) throw new BundleError(`bundle failed to load: ${msg.reason}`);
|
|
136
|
-
return {
|
|
159
|
+
return {
|
|
160
|
+
schemaHash: msg.schemaHash,
|
|
161
|
+
canonical: msg.canonical,
|
|
162
|
+
mode: msg.mode,
|
|
163
|
+
physics: msg.physics === true
|
|
164
|
+
};
|
|
137
165
|
} finally {
|
|
138
166
|
await worker.terminate();
|
|
139
167
|
}
|
|
@@ -141,6 +169,7 @@ async function verifyBundle(file) {
|
|
|
141
169
|
|
|
142
170
|
export {
|
|
143
171
|
ALLOWED_IMPORTS,
|
|
172
|
+
EXTERNAL_IMPORTS,
|
|
144
173
|
BundleError,
|
|
145
174
|
bundleRoom
|
|
146
175
|
};
|
|
@@ -46,7 +46,9 @@ async function resolveControlUrlForUser(flag) {
|
|
|
46
46
|
const explicit = flag ?? process.env.IRT_CONTROL_URL;
|
|
47
47
|
if (explicit !== void 0) return explicit;
|
|
48
48
|
const all = await readCredentials();
|
|
49
|
-
const
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const live = Object.entries(all).filter(([, cred]) => new Date(cred.expiresAt).getTime() > now).map(([url]) => url);
|
|
51
|
+
const urls = live.length > 0 ? live : Object.keys(all);
|
|
50
52
|
return urls.length === 1 ? urls[0] : DEFAULT_CONTROL_URL;
|
|
51
53
|
}
|
|
52
54
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/client-graph.ts
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as esbuild from "esbuild";
|
|
4
|
+
function externalizeBarePlugin() {
|
|
5
|
+
return {
|
|
6
|
+
name: "irtio-externalize-bare",
|
|
7
|
+
setup(build2) {
|
|
8
|
+
build2.onResolve({ filter: /^[^./]/ }, (args) => {
|
|
9
|
+
if (args.kind === "entry-point" || path.isAbsolute(args.path)) return null;
|
|
10
|
+
return { path: args.path, external: true };
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
async function clientImportsRoom(clientEntry, roomEntry) {
|
|
16
|
+
let result;
|
|
17
|
+
try {
|
|
18
|
+
result = await esbuild.build({
|
|
19
|
+
entryPoints: [clientEntry],
|
|
20
|
+
bundle: true,
|
|
21
|
+
write: false,
|
|
22
|
+
metafile: true,
|
|
23
|
+
logLevel: "silent",
|
|
24
|
+
platform: "neutral",
|
|
25
|
+
plugins: [externalizeBarePlugin()]
|
|
26
|
+
});
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const roomAbs = path.resolve(roomEntry);
|
|
31
|
+
return Object.keys(result.metafile.inputs).some((input) => path.resolve(input) === roomAbs);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
clientImportsRoom
|
|
36
|
+
};
|
|
@@ -1,19 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clientImportsRoom
|
|
3
|
+
} from "./chunk-TQU6345E.js";
|
|
1
4
|
import {
|
|
2
5
|
bundleRoom
|
|
3
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-KRQUAEN2.js";
|
|
4
7
|
import {
|
|
5
8
|
ApiClientError,
|
|
6
9
|
createApiClient,
|
|
7
10
|
isLoginRequired
|
|
8
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-I37DLT7K.js";
|
|
9
12
|
import {
|
|
10
13
|
resolveControlUrlForUser
|
|
11
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-NVUKSP5U.js";
|
|
12
15
|
|
|
13
16
|
// src/deploy.ts
|
|
14
17
|
import { existsSync } from "fs";
|
|
15
|
-
import {
|
|
16
|
-
import { tmpdir } from "os";
|
|
18
|
+
import { mkdir, readFile, readdir, rm } from "fs/promises";
|
|
17
19
|
import * as path from "path";
|
|
18
20
|
import { createInterface } from "readline/promises";
|
|
19
21
|
import { pathToFileURL } from "url";
|
|
@@ -49,6 +51,14 @@ function parseDeployArgs(args) {
|
|
|
49
51
|
parsed.room = value;
|
|
50
52
|
break;
|
|
51
53
|
}
|
|
54
|
+
case "--strategy": {
|
|
55
|
+
const value = args[++i];
|
|
56
|
+
if (value !== "drain" && value !== "migrate") {
|
|
57
|
+
throw new Error('irtio deploy: --strategy must be "drain" or "migrate"');
|
|
58
|
+
}
|
|
59
|
+
parsed.strategy = value;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
52
62
|
default:
|
|
53
63
|
throw new Error(`irtio deploy: unknown option ${JSON.stringify(arg)}`);
|
|
54
64
|
}
|
|
@@ -96,6 +106,34 @@ async function readIrtioJsonProject(cwd) {
|
|
|
96
106
|
}
|
|
97
107
|
return project;
|
|
98
108
|
}
|
|
109
|
+
async function readIrtioJsonClient(cwd) {
|
|
110
|
+
const file = path.join(cwd, "irtio.json");
|
|
111
|
+
if (!existsSync(file)) return void 0;
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
115
|
+
} catch (err) {
|
|
116
|
+
throw new Error(`irtio deploy: ${file} is not valid JSON: ${String(err)}`);
|
|
117
|
+
}
|
|
118
|
+
const client = parsed?.client;
|
|
119
|
+
if (client === void 0) return void 0;
|
|
120
|
+
if (typeof client !== "string" || client.length === 0) {
|
|
121
|
+
throw new Error(`irtio deploy: ${file} has a "client" that is not a non-empty string`);
|
|
122
|
+
}
|
|
123
|
+
return client;
|
|
124
|
+
}
|
|
125
|
+
async function warnIfClientImportsRoom(cwd, entry, log) {
|
|
126
|
+
const client = await readIrtioJsonClient(cwd);
|
|
127
|
+
if (client === void 0) return;
|
|
128
|
+
const clientFile = path.resolve(cwd, client);
|
|
129
|
+
if (await clientImportsRoom(clientFile, entry)) {
|
|
130
|
+
log(
|
|
131
|
+
pc.yellow(
|
|
132
|
+
`irtio: WARNING: the client entry ${client} imports the room file ${path.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`
|
|
133
|
+
)
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
99
137
|
async function loadBundledSchema(file) {
|
|
100
138
|
const mod = await import(pathToFileURL(file).href);
|
|
101
139
|
return {
|
|
@@ -118,13 +156,16 @@ async function findMigrationFile(cwd, version) {
|
|
|
118
156
|
const match = entries.find((f) => f.startsWith(`${version}_`) && f.endsWith(".ts"));
|
|
119
157
|
return match ? path.join(dir, match) : void 0;
|
|
120
158
|
}
|
|
159
|
+
var DEPLOY_BUILD_DIR = path.join(".irtio", "deploy");
|
|
121
160
|
async function runDeploy(options = {}) {
|
|
122
161
|
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
123
162
|
const log = options.log ?? ((line) => console.log(line));
|
|
124
163
|
const ask = options.ask ?? defaultAsk;
|
|
125
164
|
const controlUrl = await resolveControlUrlForUser(options.controlUrl);
|
|
126
165
|
const entry = resolveEntry(cwd, options.room);
|
|
127
|
-
|
|
166
|
+
await warnIfClientImportsRoom(cwd, entry, log);
|
|
167
|
+
const outDir = path.join(cwd, DEPLOY_BUILD_DIR);
|
|
168
|
+
await mkdir(outDir, { recursive: true });
|
|
128
169
|
let result;
|
|
129
170
|
try {
|
|
130
171
|
result = await bundleRoom({
|
|
@@ -158,16 +199,16 @@ async function runDeploy(options = {}) {
|
|
|
158
199
|
await client.post("/v1/projects", { name, id: projectId });
|
|
159
200
|
log(pc.dim(`created project ${projectId} (${name})`));
|
|
160
201
|
}
|
|
161
|
-
const deployments = await client.get(
|
|
162
|
-
|
|
163
|
-
);
|
|
164
|
-
const previous = deployments.reduce(
|
|
202
|
+
const deployments = await client.get(`/v1/projects/${projectId}/deployments`);
|
|
203
|
+
const newestOf = (list) => list.reduce(
|
|
165
204
|
(best, d) => best === void 0 || d.version > best.version ? d : best,
|
|
166
205
|
void 0
|
|
167
206
|
);
|
|
207
|
+
const previous = newestOf(deployments.filter((d) => d.rolledBackAt == null));
|
|
208
|
+
const newest = newestOf(deployments);
|
|
168
209
|
let allowBreaking = options.allowBreaking ?? false;
|
|
169
210
|
let migrationFile;
|
|
170
|
-
const nextVersion = (
|
|
211
|
+
const nextVersion = (newest?.version ?? 0) + 1;
|
|
171
212
|
if (previous !== void 0) {
|
|
172
213
|
const oldSchema = schemaFromCanonical(previous.schemaJson);
|
|
173
214
|
const changes = diffSchemas(oldSchema, bundled.schema);
|
|
@@ -227,6 +268,20 @@ async function runDeploy(options = {}) {
|
|
|
227
268
|
const answer = (await ask("production origin (localhost is always allowed) \u2014 leave blank to skip: ")).trim();
|
|
228
269
|
if (answer.length > 0) origin = answer;
|
|
229
270
|
}
|
|
271
|
+
const strategy = options.strategy ?? "drain";
|
|
272
|
+
if (strategy === "migrate") {
|
|
273
|
+
log(pc.yellow("strategy: migrate \u2014 live rooms move onto the new version now."));
|
|
274
|
+
log(
|
|
275
|
+
pc.yellow(
|
|
276
|
+
"Schema unchanged: connected clients see a short gap and a resync on the same socket."
|
|
277
|
+
)
|
|
278
|
+
);
|
|
279
|
+
log(
|
|
280
|
+
pc.yellow(
|
|
281
|
+
"Schema changed at all: EVERY connected client is disconnected with E_SCHEMA_MISMATCH and must reload."
|
|
282
|
+
)
|
|
283
|
+
);
|
|
284
|
+
}
|
|
230
285
|
let response;
|
|
231
286
|
try {
|
|
232
287
|
const created = await client.post(`/v1/projects/${projectId}/deployments`, {
|
|
@@ -236,13 +291,16 @@ async function runDeploy(options = {}) {
|
|
|
236
291
|
schemaHash: result.schemaHash,
|
|
237
292
|
...migrationKey !== void 0 ? { migrationKey } : {},
|
|
238
293
|
...allowBreaking ? { allowBreaking: true } : {},
|
|
239
|
-
...origin !== void 0 ? { origin } : {}
|
|
294
|
+
...origin !== void 0 ? { origin } : {},
|
|
295
|
+
...strategy !== "drain" ? { strategy } : {}
|
|
240
296
|
});
|
|
241
297
|
response = {
|
|
242
298
|
project: projectId,
|
|
243
299
|
version: created.version,
|
|
244
300
|
url: created.url,
|
|
245
|
-
draining: created.draining
|
|
301
|
+
draining: created.draining,
|
|
302
|
+
...created.applied !== void 0 ? { applied: created.applied } : {},
|
|
303
|
+
...created.rooms !== void 0 ? { rooms: created.rooms } : {}
|
|
246
304
|
};
|
|
247
305
|
} catch (err) {
|
|
248
306
|
if (err instanceof ApiClientError && err.code === "E_BREAKING_SCHEMA" && err.changes) {
|
|
@@ -259,8 +317,27 @@ async function runDeploy(options = {}) {
|
|
|
259
317
|
});
|
|
260
318
|
log(pc.green(`deployed v${response.version}`));
|
|
261
319
|
log(pc.dim(`reachable at ${response.url}`));
|
|
262
|
-
if (
|
|
263
|
-
|
|
320
|
+
if (strategy === "migrate") {
|
|
321
|
+
if (response.applied === "live") {
|
|
322
|
+
const rooms = response.rooms ?? [];
|
|
323
|
+
if (rooms.length === 0) log(pc.dim("no live rooms to migrate"));
|
|
324
|
+
for (const r of rooms) {
|
|
325
|
+
const line = ` ${r.roomId}: ${r.outcome}${r.reason ? ` \u2014 ${r.reason}` : ""}`;
|
|
326
|
+
log(r.outcome === "failed" ? pc.red(line) : pc.dim(line));
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
log(pc.dim("no tenant is running; rooms migrate on their next wake"));
|
|
330
|
+
}
|
|
331
|
+
} else if (previous !== void 0) {
|
|
332
|
+
if (response.applied === "live") {
|
|
333
|
+
log(
|
|
334
|
+
pc.dim(
|
|
335
|
+
`rooms already running finish on v${previous.version}; new rooms start on v${response.version}`
|
|
336
|
+
)
|
|
337
|
+
);
|
|
338
|
+
} else {
|
|
339
|
+
log(pc.dim(`rooms already running finish on v${previous.version}`));
|
|
340
|
+
}
|
|
264
341
|
}
|
|
265
342
|
return response;
|
|
266
343
|
}
|
|
@@ -281,6 +358,7 @@ async function deploy(args, deps = {}) {
|
|
|
281
358
|
...parsed.project !== void 0 ? { project: parsed.project } : {},
|
|
282
359
|
...parsed.url !== void 0 ? { controlUrl: parsed.url } : {},
|
|
283
360
|
...parsed.room !== void 0 ? { room: parsed.room } : {},
|
|
361
|
+
...parsed.strategy !== void 0 ? { strategy: parsed.strategy } : {},
|
|
284
362
|
...deps.client !== void 0 ? { client: deps.client } : {},
|
|
285
363
|
...deps.cwd !== void 0 ? { cwd: deps.cwd } : {},
|
|
286
364
|
...deps.irtioPackages !== void 0 ? { irtioPackages: deps.irtioPackages } : {}
|