@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/index.js
CHANGED
|
@@ -15,20 +15,41 @@ commands:
|
|
|
15
15
|
[--room <code>] [--url <ws://\u2026>] check the built-in invariants
|
|
16
16
|
[--cheat] [--key <projectKey>] --cheat sends illegal writes and expects
|
|
17
17
|
[--trace <path>] corrections; --trace dumps every frame
|
|
18
|
+
[--misprediction-max <units>] fail the run if one correction snaps a
|
|
19
|
+
[--snaps-max <n>] prediction further than this, if more than
|
|
20
|
+
[--corrections-max <perSec>] <n> corrections fall outside the resim
|
|
21
|
+
window, or above this correction rate/bot
|
|
18
22
|
|
|
19
23
|
login [--url <control>] sign in to a control plane via the browser
|
|
20
24
|
whoami [--url <control>] print the identity the stored credential
|
|
21
25
|
resolves to, and the control URL asked
|
|
22
26
|
deploy [--allow-breaking] [--project <id>] bundle, classify the schema change against the
|
|
23
27
|
[--url <control>] [--room <file>] last deployment, and upload \u2014 refuses breaking
|
|
24
|
-
|
|
28
|
+
[--strategy drain|migrate] changes unless a migration covers them;
|
|
29
|
+
migrate moves LIVE rooms onto the new version
|
|
30
|
+
now (drain, the default, lets them finish)
|
|
31
|
+
deploy --static <dir> [--project <id>] upload a BUILT client directory to the project's
|
|
32
|
+
[--label <label>] [--url <control>] static site and print the playable link
|
|
33
|
+
(<label>.irt-serve.com; no build step)
|
|
25
34
|
migrate create <name> scaffold irtio/migrations/<n>_<name>.ts with the
|
|
26
35
|
live breaking changes quoted in its header
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
rollback <version> [--project <id>] re-promote deployment <version> and bring rooms
|
|
37
|
+
[--url <control>] that migrated past it back to their
|
|
38
|
+
pre-migration state \u2014 DISCARDS what they wrote
|
|
39
|
+
since their migration
|
|
40
|
+
logs [--follow] [--since <cursor>] tail a project's logs, oldest line first;
|
|
41
|
+
[--room <id>] [--project <id>] --room narrows to a single room's stream
|
|
42
|
+
[--url <control>]
|
|
29
43
|
rooms [--project <id>] [--url <control>] list a project's rooms (rows not reported for
|
|
30
44
|
longer than the control plane's retention
|
|
31
45
|
window, 30 days by default, are pruned)
|
|
46
|
+
keys jwt-secret [--project <id>] mint a JWT signing secret (printed ONCE);
|
|
47
|
+
[retire] [--url <control>] retire finishes a rotation
|
|
48
|
+
keys jwt-mint --secret <s> --sub <player> sign a test token locally with the documented
|
|
49
|
+
[--room <id>] [--role <r>] [--ttl <sec>] recipe \u2014 the secret never leaves this machine
|
|
50
|
+
rooms saves <room> list a room's save generations, newest first
|
|
51
|
+
rooms restore <room> --save <id> bring a room back from a save \u2014 DISCARDS its
|
|
52
|
+
current state; stops the tenant if it is live
|
|
32
53
|
`;
|
|
33
54
|
switch (command) {
|
|
34
55
|
case "init": {
|
|
@@ -37,7 +58,7 @@ switch (command) {
|
|
|
37
58
|
break;
|
|
38
59
|
}
|
|
39
60
|
case "dev": {
|
|
40
|
-
const { dev } = await import("./dev-
|
|
61
|
+
const { dev } = await import("./dev-LOSTB4CD.js");
|
|
41
62
|
await dev(args);
|
|
42
63
|
break;
|
|
43
64
|
}
|
|
@@ -47,32 +68,47 @@ switch (command) {
|
|
|
47
68
|
break;
|
|
48
69
|
}
|
|
49
70
|
case "login": {
|
|
50
|
-
const { login } = await import("./login-
|
|
71
|
+
const { login } = await import("./login-OV2EFNTJ.js");
|
|
51
72
|
await login(args);
|
|
52
73
|
break;
|
|
53
74
|
}
|
|
54
75
|
case "whoami": {
|
|
55
|
-
const { whoami } = await import("./whoami-
|
|
76
|
+
const { whoami } = await import("./whoami-S73O6KJF.js");
|
|
56
77
|
await whoami(args);
|
|
57
78
|
break;
|
|
58
79
|
}
|
|
59
80
|
case "deploy": {
|
|
60
|
-
|
|
81
|
+
if (args.includes("--static")) {
|
|
82
|
+
const { staticDeploy } = await import("./static-deploy-2LB6H54Y.js");
|
|
83
|
+
await staticDeploy(args);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
const { deploy } = await import("./deploy-6OM3UYNL.js");
|
|
61
87
|
await deploy(args);
|
|
62
88
|
break;
|
|
63
89
|
}
|
|
64
90
|
case "migrate": {
|
|
65
|
-
const { migrate } = await import("./migrate-
|
|
91
|
+
const { migrate } = await import("./migrate-3R6VRW4J.js");
|
|
66
92
|
await migrate(args);
|
|
67
93
|
break;
|
|
68
94
|
}
|
|
95
|
+
case "rollback": {
|
|
96
|
+
const { rollback } = await import("./rollback-CLQVYFHW.js");
|
|
97
|
+
await rollback(args);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "keys": {
|
|
101
|
+
const { keys } = await import("./keys-KEKO3EJ6.js");
|
|
102
|
+
await keys(args);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
69
105
|
case "logs": {
|
|
70
|
-
const { logs } = await import("./logs-
|
|
106
|
+
const { logs } = await import("./logs-5OUDPAIX.js");
|
|
71
107
|
await logs(args);
|
|
72
108
|
break;
|
|
73
109
|
}
|
|
74
110
|
case "rooms": {
|
|
75
|
-
const { rooms } = await import("./rooms-
|
|
111
|
+
const { rooms } = await import("./rooms-OJ3JLYHD.js");
|
|
76
112
|
await rooms(args);
|
|
77
113
|
break;
|
|
78
114
|
}
|
package/dist/init.d.ts
CHANGED
|
@@ -19,12 +19,15 @@ interface InitArgs {
|
|
|
19
19
|
readonly dir?: string;
|
|
20
20
|
/** Set by `--tick` / `--event`; absent means "ask". */
|
|
21
21
|
readonly mode?: RoomMode;
|
|
22
|
+
/** `--physics`: a Rapier room with a shared world-builder and client prediction (D22). */
|
|
23
|
+
readonly physics?: boolean;
|
|
22
24
|
}
|
|
23
25
|
interface RunInitOptions {
|
|
24
26
|
/** Where the project lives. Defaults to `process.cwd()`. */
|
|
25
27
|
readonly cwd?: string;
|
|
26
28
|
readonly dir?: string;
|
|
27
29
|
readonly mode?: RoomMode;
|
|
30
|
+
readonly physics?: boolean;
|
|
28
31
|
/**
|
|
29
32
|
* Asks the one question. Injected so tests never touch a TTY; the default reads stdin when it
|
|
30
33
|
* is a TTY and otherwise takes the default answer rather than hanging in a pipeline.
|
package/dist/init.js
CHANGED
|
@@ -17,6 +17,9 @@ function parseInitArgs(args) {
|
|
|
17
17
|
const parsed = {};
|
|
18
18
|
for (const arg of args) {
|
|
19
19
|
switch (arg) {
|
|
20
|
+
case "--physics":
|
|
21
|
+
parsed.physics = true;
|
|
22
|
+
break;
|
|
20
23
|
case "--tick":
|
|
21
24
|
case "--event": {
|
|
22
25
|
const mode = arg === "--tick" ? "tick" : "event";
|
|
@@ -36,6 +39,9 @@ function parseInitArgs(args) {
|
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
41
|
}
|
|
42
|
+
if (parsed.physics && parsed.mode === "event") {
|
|
43
|
+
throw new Error("irtio init: --physics needs tick mode \u2014 the world steps on the fixed tick");
|
|
44
|
+
}
|
|
39
45
|
return parsed;
|
|
40
46
|
}
|
|
41
47
|
function generateProjectId() {
|
|
@@ -138,6 +144,205 @@ ${config}
|
|
|
138
144
|
${tick}});
|
|
139
145
|
`;
|
|
140
146
|
}
|
|
147
|
+
function physicsSchemaTemplate(project) {
|
|
148
|
+
return `/**
|
|
149
|
+
* Shared facts about the data. Both sides import this file \u2014 the room to run on, your game
|
|
150
|
+
* to draw from \u2014 so it also carries the project id, which is the public key: domain-locked, never
|
|
151
|
+
* secret, safe in client code.
|
|
152
|
+
*/
|
|
153
|
+
|
|
154
|
+
import { defineSchema, entity, f32, str } from '@irtio/schema';
|
|
155
|
+
|
|
156
|
+
import { rpc } from './rpc.js';
|
|
157
|
+
|
|
158
|
+
export const schema = defineSchema(
|
|
159
|
+
{
|
|
160
|
+
// One ball per connected client. The \`physics\` option is the whole idea: the simulation
|
|
161
|
+
// owns the body fields (position and velocity \u2014 read-only everywhere else, synced from the
|
|
162
|
+
// world every tick), and the owner writes only the intents (\`ax\`/\`az\`, a steering axis).
|
|
163
|
+
// Map the velocity channels too: corrections can only rebase what the schema carries, and a
|
|
164
|
+
// predicted body drifts without them.
|
|
165
|
+
players: entity(
|
|
166
|
+
{ x: f32, y: f32, z: f32, vx: f32, vy: f32, vz: f32, ax: f32, az: f32, name: str(24) },
|
|
167
|
+
{
|
|
168
|
+
physics: {
|
|
169
|
+
body: { x: 'x', y: 'y', z: 'z', vx: 'vx', vy: 'vy', vz: 'vz' },
|
|
170
|
+
intents: ['ax', 'az'],
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
),
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
project: '${project}',
|
|
177
|
+
rpc,
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
var WORLD_TEMPLATE = `/**
|
|
183
|
+
* The shared world-builder (D22): one module, imported by BOTH sides. The room's physics config
|
|
184
|
+
* uses these exports to build the authoritative world; the client passes the same exports to
|
|
185
|
+
* \`joinRoom({ physics })\` and predicts against an identical local world. "Same code both
|
|
186
|
+
* sides" is the geometry contract \u2014 this file's name is only the convention \`irtio init\`
|
|
187
|
+
* follows, not a requirement.
|
|
188
|
+
*
|
|
189
|
+
* Two rules keep it honest:
|
|
190
|
+
* - **Pure over synced inputs.** No \`Math.random()\`, no clock, no module state \u2014 put seeds
|
|
191
|
+
* and level parameters in synced state, so both sides build bit-identical worlds
|
|
192
|
+
* (\`irtio simulate\` builds it twice and compares).
|
|
193
|
+
* - **Never import room.ts from here or from client code.** Room code is server-only; shared
|
|
194
|
+
* code lives in modules like this one. (\`irtio deploy\` warns if the client's import graph
|
|
195
|
+
* reaches the room file.)
|
|
196
|
+
*/
|
|
197
|
+
|
|
198
|
+
import type RAPIER from '@dimforge/rapier3d-compat';
|
|
199
|
+
|
|
200
|
+
type Rapier = typeof RAPIER;
|
|
201
|
+
type World = RAPIER.World;
|
|
202
|
+
type Body = RAPIER.RigidBody;
|
|
203
|
+
|
|
204
|
+
export const gravity = { x: 0, y: -9.81, z: 0 } as const;
|
|
205
|
+
|
|
206
|
+
export const ARENA_HALF = 20;
|
|
207
|
+
export const BALL_RADIUS = 0.5;
|
|
208
|
+
/** Horizontal impulse per tick at full stick. */
|
|
209
|
+
export const NUDGE = 0.4;
|
|
210
|
+
|
|
211
|
+
/** Static geometry: a floor and four walls. */
|
|
212
|
+
export function setup(world: World, rapier: Rapier): void {
|
|
213
|
+
const floor = world.createRigidBody(rapier.RigidBodyDesc.fixed().setTranslation(0, -0.5, 0));
|
|
214
|
+
world.createCollider(rapier.ColliderDesc.cuboid(ARENA_HALF + 2, 0.5, ARENA_HALF + 2), floor);
|
|
215
|
+
for (const [x, z] of [
|
|
216
|
+
[ARENA_HALF + 1, 0],
|
|
217
|
+
[-(ARENA_HALF + 1), 0],
|
|
218
|
+
[0, ARENA_HALF + 1],
|
|
219
|
+
[0, -(ARENA_HALF + 1)],
|
|
220
|
+
] as const) {
|
|
221
|
+
const wall = world.createRigidBody(rapier.RigidBodyDesc.fixed().setTranslation(x, 5, z));
|
|
222
|
+
world.createCollider(
|
|
223
|
+
rapier.ColliderDesc.cuboid(x === 0 ? ARENA_HALF + 2 : 1, 6, z === 0 ? ARENA_HALF + 2 : 1),
|
|
224
|
+
wall,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** How one instance becomes a rigid body \u2014 shape and material only; \`add()\` places it. */
|
|
230
|
+
export const bodies = {
|
|
231
|
+
players: (rapier: Rapier) => ({
|
|
232
|
+
body: rapier.RigidBodyDesc.dynamic().lockRotations(),
|
|
233
|
+
colliders: [rapier.ColliderDesc.ball(BALL_RADIUS).setRestitution(0.3)],
|
|
234
|
+
}),
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/** Intent \u2192 force, once per step. The room's tick() and the client's predictor both call this. */
|
|
238
|
+
export const intents = {
|
|
239
|
+
players: (body: Body, player: { ax: number; az: number }): void => {
|
|
240
|
+
if (player.ax === 0 && player.az === 0) return;
|
|
241
|
+
body.applyImpulse({ x: player.ax * NUDGE, y: 0, z: player.az * NUDGE }, true);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
`;
|
|
245
|
+
function physicsRoomTemplate() {
|
|
246
|
+
return `/**
|
|
247
|
+
* Behavior. Server-only: \`irtio dev\` and \`irtio deploy\` bundle this file, and it never
|
|
248
|
+
* reaches the browser. The world's geometry, shapes and intent handling come from the shared
|
|
249
|
+
* world module, so the client predicts with exactly the code the server runs.
|
|
250
|
+
*/
|
|
251
|
+
|
|
252
|
+
import { defineRoom } from '@irtio/server';
|
|
253
|
+
|
|
254
|
+
import { schema } from './schema.js';
|
|
255
|
+
import { bodies, gravity, intents, setup } from './world.js';
|
|
256
|
+
|
|
257
|
+
export default defineRoom(schema, {
|
|
258
|
+
mode: 'tick',
|
|
259
|
+
tickRate: ${DEFAULT_TICK_RATE},
|
|
260
|
+
|
|
261
|
+
physics: {
|
|
262
|
+
engine: 'rapier3d',
|
|
263
|
+
gravity: { ...gravity },
|
|
264
|
+
setup(world, rapier) {
|
|
265
|
+
setup(world, rapier);
|
|
266
|
+
},
|
|
267
|
+
bodies,
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
onJoin(state, ctx) {
|
|
271
|
+
if (ctx.reconnecting || state.players.has(ctx.clientId)) return;
|
|
272
|
+
// Body fields are the spawn pose: add() places the body. Deterministic spawn spots, spread
|
|
273
|
+
// apart \u2014 overlapping spawns resolve against each other, and another player's body is not
|
|
274
|
+
// in your local prediction world.
|
|
275
|
+
state.players.add(
|
|
276
|
+
ctx.clientId,
|
|
277
|
+
{
|
|
278
|
+
x: (state.players.size % 5) * 6 - 12,
|
|
279
|
+
y: 4,
|
|
280
|
+
z: Math.floor(state.players.size / 5) * 6 - 6,
|
|
281
|
+
vx: 0,
|
|
282
|
+
vy: 0,
|
|
283
|
+
vz: 0,
|
|
284
|
+
ax: 0,
|
|
285
|
+
az: 0,
|
|
286
|
+
name: ctx.name || 'anon',
|
|
287
|
+
},
|
|
288
|
+
{ owner: ctx.clientId },
|
|
289
|
+
);
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
onLeave(state, ctx) {
|
|
293
|
+
state.players.remove(ctx.clientId);
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
// Intents are the only thing a client can write on a physics entity; clamp them here.
|
|
297
|
+
validate: {
|
|
298
|
+
players: (_prev, next) => ({
|
|
299
|
+
...next,
|
|
300
|
+
ax: Math.max(-1, Math.min(1, next.ax)),
|
|
301
|
+
az: Math.max(-1, Math.min(1, next.az)),
|
|
302
|
+
}),
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
tick(state, _dt, room) {
|
|
306
|
+
for (const [id, player] of state.players) {
|
|
307
|
+
const body = room.physics.body('players', id);
|
|
308
|
+
if (body) intents.players(body, player);
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
`;
|
|
313
|
+
}
|
|
314
|
+
var PHYSICS_ROOM_TEST_TEMPLATE = `/**
|
|
315
|
+
* The smoke test \`irtio init --physics\` scaffolds. The engine's WASM needs one await before
|
|
316
|
+
* any room exists \u2014 the worker host does it in production, the test does it here.
|
|
317
|
+
*/
|
|
318
|
+
|
|
319
|
+
import { beforeAll, expect, test } from 'vitest';
|
|
320
|
+
|
|
321
|
+
import { initPhysics, testRoom } from '@irtio/testing';
|
|
322
|
+
import '@irtio/testing/matchers';
|
|
323
|
+
|
|
324
|
+
import room from './room.js';
|
|
325
|
+
|
|
326
|
+
beforeAll(async () => {
|
|
327
|
+
await initPhysics();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('a player joins, falls to the floor, and steers with an intent', async () => {
|
|
331
|
+
const t = await testRoom(room);
|
|
332
|
+
const [a] = await t.join(1);
|
|
333
|
+
await t.tick(60);
|
|
334
|
+
|
|
335
|
+
const ball = t.state.players.get(a!.me);
|
|
336
|
+
expect(ball).toBeDefined();
|
|
337
|
+
expect(ball!.y).toBeLessThan(1); // it fell and came to rest on the floor
|
|
338
|
+
|
|
339
|
+
const before = ball!.x;
|
|
340
|
+
a!.state.players.get(a!.me)!.ax = 1;
|
|
341
|
+
a!.flush();
|
|
342
|
+
await t.tick(20);
|
|
343
|
+
expect(t.state.players.get(a!.me)!.x).toBeGreaterThan(before); // the intent moved it
|
|
344
|
+
});
|
|
345
|
+
`;
|
|
141
346
|
var ROOM_TEST_TEMPLATE = `/**
|
|
142
347
|
* The smoke test \`irtio init\` scaffolds: two clients join, one writes a field it owns, and every
|
|
143
348
|
* view agrees with the server. \`testRoom\` runs the real room and real client semantics in
|
|
@@ -211,7 +416,7 @@ async function defaultAsk(question) {
|
|
|
211
416
|
rl.close();
|
|
212
417
|
}
|
|
213
418
|
}
|
|
214
|
-
async function updatePackageJson(file) {
|
|
419
|
+
async function updatePackageJson(file, wanted) {
|
|
215
420
|
const source = await readFile(file, "utf8");
|
|
216
421
|
let parsed;
|
|
217
422
|
try {
|
|
@@ -221,7 +426,7 @@ async function updatePackageJson(file) {
|
|
|
221
426
|
}
|
|
222
427
|
const deps = { ...parsed.dependencies ?? {} };
|
|
223
428
|
let changed = false;
|
|
224
|
-
for (const name of
|
|
429
|
+
for (const name of wanted) {
|
|
225
430
|
if (deps[name] === void 0) {
|
|
226
431
|
deps[name] = "^0.0.0";
|
|
227
432
|
changed = true;
|
|
@@ -240,8 +445,11 @@ async function runInit(options = {}) {
|
|
|
240
445
|
const dir = path.resolve(cwd, options.dir ?? ".");
|
|
241
446
|
const log = options.log ?? ((line) => console.log(line));
|
|
242
447
|
const ask = options.ask ?? defaultAsk;
|
|
448
|
+
const physics = options.physics === true;
|
|
243
449
|
let mode = options.mode;
|
|
244
|
-
if (
|
|
450
|
+
if (physics) {
|
|
451
|
+
mode = "tick";
|
|
452
|
+
} else if (mode === void 0) {
|
|
245
453
|
const answer = (await ask("does anything move without a player doing something? (y/n) ")).trim().toLowerCase();
|
|
246
454
|
mode = answer.startsWith("n") ? "event" : "tick";
|
|
247
455
|
}
|
|
@@ -255,7 +463,15 @@ async function runInit(options = {}) {
|
|
|
255
463
|
} catch {
|
|
256
464
|
}
|
|
257
465
|
}
|
|
258
|
-
const files = [
|
|
466
|
+
const files = physics ? [
|
|
467
|
+
["irtio/schema.ts", physicsSchemaTemplate(project)],
|
|
468
|
+
["irtio/rpc.ts", RPC_TEMPLATE],
|
|
469
|
+
["irtio/world.ts", WORLD_TEMPLATE],
|
|
470
|
+
["irtio/room.ts", physicsRoomTemplate()],
|
|
471
|
+
["irtio/room.test.ts", PHYSICS_ROOM_TEST_TEMPLATE],
|
|
472
|
+
["irtio.json", `${JSON.stringify({ project }, null, 2)}
|
|
473
|
+
`]
|
|
474
|
+
] : [
|
|
259
475
|
["irtio/schema.ts", schemaTemplate(project)],
|
|
260
476
|
["irtio/rpc.ts", RPC_TEMPLATE],
|
|
261
477
|
["irtio/room.ts", roomTemplate(mode)],
|
|
@@ -275,15 +491,26 @@ async function runInit(options = {}) {
|
|
|
275
491
|
await writeFile(file, contents, "utf8");
|
|
276
492
|
created.push(relative);
|
|
277
493
|
}
|
|
494
|
+
const wantedDeps = physics ? [...INIT_DEPENDENCIES, "@dimforge/rapier3d-compat"] : [...INIT_DEPENDENCIES];
|
|
278
495
|
const packageFile = path.join(dir, "package.json");
|
|
279
|
-
const packageJson = existsSync(packageFile) ? await updatePackageJson(packageFile) : "absent";
|
|
496
|
+
const packageJson = existsSync(packageFile) ? await updatePackageJson(packageFile, wantedDeps) : "absent";
|
|
280
497
|
if (created.length > 0) log(pc.green(`created ${created.join(", ")}`));
|
|
281
498
|
if (skipped.length > 0) {
|
|
282
499
|
log(pc.yellow(`kept ${skipped.join(", ")} (already there \u2014 nothing was overwritten)`));
|
|
283
500
|
}
|
|
284
|
-
log(pc.dim(`project ${project} \xB7 ${mode} mode`));
|
|
285
|
-
const installCommand = packageJson === "absent" ? `npm i ${
|
|
501
|
+
log(pc.dim(`project ${project} \xB7 ${mode} mode${physics ? " \xB7 physics" : ""}`));
|
|
502
|
+
const installCommand = packageJson === "absent" ? `npm i ${wantedDeps.join(" ")}` : "npm install # the irtio packages were added to your package.json";
|
|
286
503
|
for (const line of nextSteps(installCommand)) log(line);
|
|
504
|
+
if (physics) {
|
|
505
|
+
log(" physics: the world lives in irtio/world.ts, imported by BOTH sides. The client");
|
|
506
|
+
log(" predicts its own body with the same code \u2014 pass it to joinRoom:");
|
|
507
|
+
log("");
|
|
508
|
+
log(pc.cyan(" import * as world from './irtio/world.js';"));
|
|
509
|
+
log(pc.cyan(" const room = await joinRoom(schema, { physics: world });"));
|
|
510
|
+
log("");
|
|
511
|
+
log(" draw from room.render (predicted + interpolated), write intents via room.state.");
|
|
512
|
+
log("");
|
|
513
|
+
}
|
|
287
514
|
return { dir, project, mode, created, skipped, packageJson };
|
|
288
515
|
}
|
|
289
516
|
async function init(args) {
|
|
@@ -291,7 +518,8 @@ async function init(args) {
|
|
|
291
518
|
const parsed = parseInitArgs(args);
|
|
292
519
|
await runInit({
|
|
293
520
|
...parsed.dir !== void 0 ? { dir: parsed.dir } : {},
|
|
294
|
-
...parsed.mode !== void 0 ? { mode: parsed.mode } : {}
|
|
521
|
+
...parsed.mode !== void 0 ? { mode: parsed.mode } : {},
|
|
522
|
+
...parsed.physics !== void 0 ? { physics: parsed.physics } : {}
|
|
295
523
|
});
|
|
296
524
|
} catch (err) {
|
|
297
525
|
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createApiClient,
|
|
3
|
+
isLoginRequired
|
|
4
|
+
} from "./chunk-I37DLT7K.js";
|
|
5
|
+
import {
|
|
6
|
+
resolveControlUrlForUser
|
|
7
|
+
} from "./chunk-NVUKSP5U.js";
|
|
8
|
+
|
|
9
|
+
// src/keys.ts
|
|
10
|
+
import { existsSync } from "fs";
|
|
11
|
+
import { readFile } from "fs/promises";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import pc from "picocolors";
|
|
14
|
+
|
|
15
|
+
// src/jwt.ts
|
|
16
|
+
import { createHmac } from "crypto";
|
|
17
|
+
function signJwt(secret, claims) {
|
|
18
|
+
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
|
19
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString("base64url");
|
|
20
|
+
const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
|
|
21
|
+
return `${header}.${payload}.${signature}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/keys.ts
|
|
25
|
+
async function readIrtioJsonProject(cwd) {
|
|
26
|
+
const file = path.join(cwd, "irtio.json");
|
|
27
|
+
if (!existsSync(file)) return void 0;
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error(`irtio keys: ${file} is not valid JSON`);
|
|
33
|
+
}
|
|
34
|
+
return typeof parsed.project === "string" && parsed.project.length > 0 ? parsed.project : void 0;
|
|
35
|
+
}
|
|
36
|
+
function flagValue(args, flag) {
|
|
37
|
+
const i = args.indexOf(flag);
|
|
38
|
+
if (i === -1) return void 0;
|
|
39
|
+
const value = args[i + 1];
|
|
40
|
+
if (value === void 0) throw new Error(`irtio keys: ${flag} needs a value`);
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
async function resolveProject(args) {
|
|
44
|
+
const project = flagValue(args, "--project") ?? await readIrtioJsonProject(process.cwd());
|
|
45
|
+
if (project === void 0) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
"irtio keys: no project id \u2014 pass --project or run this from a project with irtio.json"
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return project;
|
|
51
|
+
}
|
|
52
|
+
var DEFAULT_ISSUER = "main";
|
|
53
|
+
async function runJwtSecret(options) {
|
|
54
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
55
|
+
const controlUrl = await resolveControlUrlForUser(options.controlUrl);
|
|
56
|
+
const client = options.client ?? await createApiClient(controlUrl);
|
|
57
|
+
const issuer = options.issuer ?? DEFAULT_ISSUER;
|
|
58
|
+
const result = await client.post(`/v1/projects/${options.project}/jwt-secret`, {
|
|
59
|
+
issuer
|
|
60
|
+
});
|
|
61
|
+
log(pc.green(`minted a JWT signing secret for ${options.project} (issuer ${result.issuer})`));
|
|
62
|
+
log("");
|
|
63
|
+
log(` ${pc.bold(result.secret)}`);
|
|
64
|
+
log("");
|
|
65
|
+
log(pc.yellow("this is the only time you will see it \u2014 store it on your server now"));
|
|
66
|
+
if (result.active > 1) {
|
|
67
|
+
log(
|
|
68
|
+
pc.dim(
|
|
69
|
+
"two secrets are now active: tokens signed with either verify. Move your server to this one, then run `irtio keys jwt-secret retire` to finish the rotation."
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
log(
|
|
74
|
+
pc.dim(
|
|
75
|
+
"the tenant picks the secret up at its next placement (a stop and start), not mid-flight"
|
|
76
|
+
)
|
|
77
|
+
);
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
async function runJwtSecretRetire(options) {
|
|
81
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
82
|
+
const controlUrl = await resolveControlUrlForUser(options.controlUrl);
|
|
83
|
+
const client = options.client ?? await createApiClient(controlUrl);
|
|
84
|
+
const result = await client.post(
|
|
85
|
+
`/v1/projects/${options.project}/jwt-secret/retire`,
|
|
86
|
+
{ issuer: options.issuer ?? DEFAULT_ISSUER }
|
|
87
|
+
);
|
|
88
|
+
log(pc.green(`retired the oldest active secret; ${result.active} remain(s) active`));
|
|
89
|
+
log(pc.dim("tokens signed with the retired secret stop verifying at the next placement"));
|
|
90
|
+
}
|
|
91
|
+
async function keys(args, deps = {}) {
|
|
92
|
+
const log = deps.log ?? ((line) => console.log(line));
|
|
93
|
+
const errorLog = deps.errorLog ?? ((line) => console.error(line));
|
|
94
|
+
try {
|
|
95
|
+
const sub = args[0];
|
|
96
|
+
const rest = args.slice(1);
|
|
97
|
+
if (sub === "jwt-secret" && rest[0] === "retire") {
|
|
98
|
+
const project = await resolveProject(rest);
|
|
99
|
+
const url = flagValue(rest, "--url");
|
|
100
|
+
const issuer = flagValue(rest, "--issuer");
|
|
101
|
+
await runJwtSecretRetire({
|
|
102
|
+
project,
|
|
103
|
+
log,
|
|
104
|
+
...issuer !== void 0 ? { issuer } : {},
|
|
105
|
+
...url !== void 0 ? { controlUrl: url } : {},
|
|
106
|
+
...deps.client !== void 0 ? { client: deps.client } : {}
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (sub === "jwt-secret") {
|
|
111
|
+
const project = await resolveProject(rest);
|
|
112
|
+
const url = flagValue(rest, "--url");
|
|
113
|
+
const issuer = flagValue(rest, "--issuer");
|
|
114
|
+
await runJwtSecret({
|
|
115
|
+
project,
|
|
116
|
+
log,
|
|
117
|
+
...issuer !== void 0 ? { issuer } : {},
|
|
118
|
+
...url !== void 0 ? { controlUrl: url } : {},
|
|
119
|
+
...deps.client !== void 0 ? { client: deps.client } : {}
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (sub === "jwt-mint") {
|
|
124
|
+
const secret = flagValue(rest, "--secret");
|
|
125
|
+
const subject = flagValue(rest, "--sub");
|
|
126
|
+
if (secret === void 0 || subject === void 0) {
|
|
127
|
+
throw new Error("irtio keys jwt-mint: --secret <secret> and --sub <playerId> are required");
|
|
128
|
+
}
|
|
129
|
+
const ttl = Number(flagValue(rest, "--ttl") ?? 3600);
|
|
130
|
+
if (!Number.isFinite(ttl) || ttl <= 0) {
|
|
131
|
+
throw new Error("irtio keys jwt-mint: --ttl must be a positive number of seconds");
|
|
132
|
+
}
|
|
133
|
+
const room = flagValue(rest, "--room");
|
|
134
|
+
const role = flagValue(rest, "--role");
|
|
135
|
+
const iss = flagValue(rest, "--iss") ?? DEFAULT_ISSUER;
|
|
136
|
+
const project = flagValue(rest, "--project") ?? await readIrtioJsonProject(process.cwd());
|
|
137
|
+
if (project === void 0) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
"irtio keys jwt-mint: --project is required (aud must name the project the token is for)"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
143
|
+
const token = signJwt(secret, {
|
|
144
|
+
iss,
|
|
145
|
+
aud: project,
|
|
146
|
+
sub: subject,
|
|
147
|
+
iat: now,
|
|
148
|
+
exp: now + Math.floor(ttl),
|
|
149
|
+
...room !== void 0 ? { roomId: room } : {},
|
|
150
|
+
...role !== void 0 ? { role } : {}
|
|
151
|
+
});
|
|
152
|
+
log(token);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
throw new Error(
|
|
156
|
+
"irtio keys: unknown subcommand \u2014 try `keys jwt-secret`, `keys jwt-secret retire`, or `keys jwt-mint`"
|
|
157
|
+
);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (isLoginRequired(err)) {
|
|
160
|
+
errorLog(pc.red("not logged in"));
|
|
161
|
+
errorLog("run: irtio login");
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
errorLog(pc.red(err instanceof Error ? err.message : String(err)));
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
export {
|
|
170
|
+
DEFAULT_ISSUER,
|
|
171
|
+
keys,
|
|
172
|
+
runJwtSecret,
|
|
173
|
+
runJwtSecretRetire
|
|
174
|
+
};
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
credentialsPath,
|
|
3
3
|
resolveControlUrl,
|
|
4
4
|
writeCredential
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-NVUKSP5U.js";
|
|
6
6
|
|
|
7
7
|
// src/login.ts
|
|
8
8
|
import { spawn } from "child_process";
|
|
@@ -49,6 +49,11 @@ async function runLogin(options = {}) {
|
|
|
49
49
|
const openBrowser = options.openBrowser ?? defaultOpenBrowser;
|
|
50
50
|
const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
|
|
51
51
|
const state = randomBytes(8).toString("hex");
|
|
52
|
+
const allowedOrigin = new URL(controlUrl).origin;
|
|
53
|
+
const corsHeaders = {
|
|
54
|
+
"access-control-allow-origin": allowedOrigin,
|
|
55
|
+
vary: "origin"
|
|
56
|
+
};
|
|
52
57
|
return new Promise((resolve, reject) => {
|
|
53
58
|
let settled = false;
|
|
54
59
|
const finish = (fn) => {
|
|
@@ -64,7 +69,23 @@ async function runLogin(options = {}) {
|
|
|
64
69
|
res.writeHead(403).end();
|
|
65
70
|
return;
|
|
66
71
|
}
|
|
67
|
-
if (req.
|
|
72
|
+
if (req.url?.split("?")[0] !== "/token") {
|
|
73
|
+
res.writeHead(404).end();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (req.method === "OPTIONS") {
|
|
77
|
+
const headers = {
|
|
78
|
+
...corsHeaders,
|
|
79
|
+
"access-control-allow-methods": "POST",
|
|
80
|
+
"access-control-allow-headers": "content-type"
|
|
81
|
+
};
|
|
82
|
+
if (req.headers["access-control-request-private-network"] === "true") {
|
|
83
|
+
headers["access-control-allow-private-network"] = "true";
|
|
84
|
+
}
|
|
85
|
+
res.writeHead(204, headers).end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (req.method !== "POST") {
|
|
68
89
|
res.writeHead(404).end();
|
|
69
90
|
return;
|
|
70
91
|
}
|
|
@@ -72,7 +93,7 @@ async function runLogin(options = {}) {
|
|
|
72
93
|
const body = await readBody(req);
|
|
73
94
|
const parsed = JSON.parse(body);
|
|
74
95
|
if (typeof parsed.token !== "string" || typeof parsed.expiresAt !== "string") {
|
|
75
|
-
res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: "expected { token, expiresAt }" }));
|
|
96
|
+
res.writeHead(400, { "content-type": "application/json", ...corsHeaders }).end(JSON.stringify({ error: "expected { token, expiresAt }" }));
|
|
76
97
|
return;
|
|
77
98
|
}
|
|
78
99
|
const credential = {
|
|
@@ -81,10 +102,10 @@ async function runLogin(options = {}) {
|
|
|
81
102
|
...typeof parsed.email === "string" && parsed.email !== "" ? { email: parsed.email } : {}
|
|
82
103
|
};
|
|
83
104
|
const file = await writeCredential(controlUrl, credential);
|
|
84
|
-
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
|
|
105
|
+
res.writeHead(200, { "content-type": "application/json", ...corsHeaders }).end(JSON.stringify({ ok: true }));
|
|
85
106
|
finish(() => resolve({ controlUrl, email: credential.email, file }));
|
|
86
107
|
} catch (err) {
|
|
87
|
-
res.writeHead(500).end();
|
|
108
|
+
res.writeHead(500, corsHeaders).end();
|
|
88
109
|
finish(() => reject(err instanceof Error ? err : new Error(String(err))));
|
|
89
110
|
}
|
|
90
111
|
})();
|