@spawnco/client 0.1.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/README.md +54 -0
- package/bin/spawn.mjs +6 -0
- package/dist/main.mjs +38497 -0
- package/dist/session-lib.mjs +765 -0
- package/dist/spawn.mjs +1389 -0
- package/package.json +33 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../apps/cf-kernel/src/_entry/headless-room-host/session/launch.ts
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2 } from "fs";
|
|
5
|
+
import { createServer } from "net";
|
|
6
|
+
import path2 from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
|
|
9
|
+
// ../../apps/cf-kernel/src/waist/guards.ts
|
|
10
|
+
function isObject(value) {
|
|
11
|
+
return typeof value === "object" && value !== null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ../../apps/cf-kernel/src/_entry/headless-room-host/session/account-lane.ts
|
|
15
|
+
var ACCOUNT_TOKEN_PREFIX = "sak_";
|
|
16
|
+
var DEFAULT_SPAWN_ORIGIN = "https://www.spawn.co";
|
|
17
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
|
+
var ENGINE_HASH_RE = /^[0-9a-f]{32}$/;
|
|
19
|
+
var isAccountToken = (token) => typeof token === "string" && token.trim().startsWith(ACCOUNT_TOKEN_PREFIX);
|
|
20
|
+
var trimSlash = (value) => value.replace(/\/+$/, "");
|
|
21
|
+
var nonEmpty = (value) => {
|
|
22
|
+
const trimmed = value?.trim();
|
|
23
|
+
return trimmed !== undefined && trimmed.length > 0 ? trimmed : undefined;
|
|
24
|
+
};
|
|
25
|
+
var originOf = (url) => {
|
|
26
|
+
try {
|
|
27
|
+
return new URL(url).origin;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
var asHttp = (origin) => origin.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
|
|
33
|
+
async function fetchJson(fetchImpl, url, init) {
|
|
34
|
+
let response;
|
|
35
|
+
try {
|
|
36
|
+
response = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(15000) });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return { status: 0, body: { error: `fetch failed: ${error instanceof Error ? error.message : String(error)}` } };
|
|
39
|
+
}
|
|
40
|
+
const body = await response.json().catch(() => null);
|
|
41
|
+
return { status: response.status, body };
|
|
42
|
+
}
|
|
43
|
+
var errorLine = (body, status) => {
|
|
44
|
+
if (isObject(body)) {
|
|
45
|
+
const parts = [typeof body.error === "string" ? body.error : null, typeof body.verdict === "string" ? body.verdict : null, typeof body.note === "string" ? body.note : null].filter((part) => part !== null);
|
|
46
|
+
if (parts.length > 0)
|
|
47
|
+
return parts.join(" \u2014 ");
|
|
48
|
+
}
|
|
49
|
+
return `HTTP ${status}`;
|
|
50
|
+
};
|
|
51
|
+
function readPlayDoors(me, origin) {
|
|
52
|
+
const doors = isObject(me) && isObject(me.doors) ? me.doors : null;
|
|
53
|
+
const play = doors && isObject(doors.play) ? doors.play : null;
|
|
54
|
+
const fallback = { resolve: `${origin}/api/resolve-adventure`, grant: `${origin}/api/session/grant/agent`, attach: null };
|
|
55
|
+
if (!play)
|
|
56
|
+
return fallback;
|
|
57
|
+
return {
|
|
58
|
+
resolve: typeof play.resolve === "string" ? play.resolve : fallback.resolve,
|
|
59
|
+
grant: typeof play.grant === "string" ? play.grant : fallback.grant,
|
|
60
|
+
attach: typeof play.attach === "string" ? play.attach : null
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function resolveWorldId(fetchImpl, doors, world) {
|
|
64
|
+
if (UUID_RE.test(world))
|
|
65
|
+
return { ok: true, worldId: world.toLowerCase(), address: world.toLowerCase() };
|
|
66
|
+
const url = `${doors.resolve}${doors.resolve.includes("?") ? "&" : "?"}address=${encodeURIComponent(world)}`;
|
|
67
|
+
const { status, body } = await fetchJson(fetchImpl, url, { method: "GET" });
|
|
68
|
+
if (status !== 200 || !isObject(body) || body.ok !== true || !isObject(body.result)) {
|
|
69
|
+
return { ok: false, verdict: `could not resolve "${world}" (${errorLine(body, status)}) \u2014 spell the world as @user/world, its play URL, or its uuid` };
|
|
70
|
+
}
|
|
71
|
+
const result = body.result;
|
|
72
|
+
if (result.type === "found" && typeof result.rootVariantId === "string") {
|
|
73
|
+
const username = typeof result.username === "string" ? result.username : null;
|
|
74
|
+
const adventure = typeof result.adventureName === "string" ? result.adventureName : null;
|
|
75
|
+
return { ok: true, worldId: result.rootVariantId, address: username && adventure ? `@${username}/${adventure}` : world };
|
|
76
|
+
}
|
|
77
|
+
if (result.type === "redirect" && typeof result.username === "string" && typeof result.adventureName === "string") {
|
|
78
|
+
return { ok: false, verdict: `"${world}" moved \u2014 it lives at @${result.username}/${result.adventureName} now; join that address` };
|
|
79
|
+
}
|
|
80
|
+
return { ok: false, verdict: `no world at "${world}" \u2014 check the address (the public ones are in GET /api/feed)` };
|
|
81
|
+
}
|
|
82
|
+
async function mintGrant(fetchImpl, doors, token, worldId, agentActorId) {
|
|
83
|
+
const { status, body } = await fetchJson(fetchImpl, doors.grant, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ world: worldId, ...agentActorId !== undefined ? { agentActorId } : {} })
|
|
87
|
+
});
|
|
88
|
+
if (status === 401)
|
|
89
|
+
return { ok: false, verdict: `the token was refused (${errorLine(body, status)}) \u2014 an agent signs up at POST /api/agent/v1/signup; a human mints a token at /settings/tokens` };
|
|
90
|
+
if (status === 400 && isObject(body) && typeof body.error === "string" && /agentActorId/.test(body.error)) {
|
|
91
|
+
return { ok: false, verdict: `${body.error} \u2014 a person's token embodies a seat it minted: pass --actor <agentActorId> (an agent account's own token needs none)` };
|
|
92
|
+
}
|
|
93
|
+
if (status !== 200 || !isObject(body) || typeof body.grantWire !== "string") {
|
|
94
|
+
return { ok: false, verdict: `the grant was refused for world ${worldId} (${errorLine(body, status)})` };
|
|
95
|
+
}
|
|
96
|
+
const engine = isObject(body.engine) ? body.engine : null;
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
grant: {
|
|
100
|
+
grantWire: body.grantWire,
|
|
101
|
+
presentationWire: typeof body.presentationWire === "string" && body.presentationWire.length > 0 ? body.presentationWire : null,
|
|
102
|
+
worldId: typeof body.worldId === "string" && body.worldId.length > 0 ? body.worldId : null,
|
|
103
|
+
attachUrl: typeof body.attachUrl === "string" && body.attachUrl.length > 0 ? body.attachUrl : null,
|
|
104
|
+
engineHash: engine && typeof engine.hash === "string" && ENGINE_HASH_RE.test(engine.hash) ? engine.hash : null,
|
|
105
|
+
engineSemver: engine && typeof engine.semver === "string" && engine.semver.length > 0 ? engine.semver : null
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
async function discoverEngineHash(fetchImpl, doorOrigin, worldId, grantWire) {
|
|
110
|
+
const http = asHttp(doorOrigin);
|
|
111
|
+
try {
|
|
112
|
+
const shell = await fetchImpl(`${http}/session/${encodeURIComponent(worldId)}?grant=${encodeURIComponent(grantWire)}`, { signal: AbortSignal.timeout(15000) });
|
|
113
|
+
if (shell.ok) {
|
|
114
|
+
const html = await shell.text();
|
|
115
|
+
const match = /\/v4\/client\/([0-9a-f]{32})\//.exec(html);
|
|
116
|
+
if (match?.[1])
|
|
117
|
+
return { hash: match[1], source: "shell" };
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
try {
|
|
121
|
+
const stamp = await fetchImpl(`${http}/build-stamp.json`, { signal: AbortSignal.timeout(1e4) });
|
|
122
|
+
if (stamp.ok) {
|
|
123
|
+
const body = await stamp.json().catch(() => null);
|
|
124
|
+
const fingerprint = isObject(body) && typeof body.fingerprint === "string" ? body.fingerprint.slice(0, 32).toLowerCase() : null;
|
|
125
|
+
if (fingerprint && ENGINE_HASH_RE.test(fingerprint))
|
|
126
|
+
return { hash: fingerprint, source: "build-stamp" };
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
async function resolveAccountJoin(deps, request) {
|
|
132
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
133
|
+
const token = nonEmpty(deps.env.SPAWN_TOKEN);
|
|
134
|
+
if (!token || !isAccountToken(token))
|
|
135
|
+
return { ok: false, verdict: "spawn client join <world> needs your account token in SPAWN_TOKEN (sak_\u2026) \u2014 an agent signs up at POST /api/agent/v1/signup; a human mints one at /settings/tokens" };
|
|
136
|
+
const origin = trimSlash(nonEmpty(deps.env.SPAWN_ORIGIN) ?? DEFAULT_SPAWN_ORIGIN);
|
|
137
|
+
const me = await fetchJson(fetchImpl, `${origin}/api/agent/v1/me`, { method: "GET", headers: { authorization: `Bearer ${token}` } });
|
|
138
|
+
if (me.status === 401)
|
|
139
|
+
return { ok: false, verdict: `${origin} refused the token (${errorLine(me.body, me.status)}) \u2014 an agent signs up at POST /api/agent/v1/signup; a human mints a token at /settings/tokens` };
|
|
140
|
+
if (me.status !== 200 || !isObject(me.body))
|
|
141
|
+
return { ok: false, verdict: `${origin}/api/agent/v1/me answered ${errorLine(me.body, me.status)} \u2014 is SPAWN_ORIGIN right?` };
|
|
142
|
+
const doors = readPlayDoors(me.body, origin);
|
|
143
|
+
const handle = typeof me.body.handle === "string" ? me.body.handle : null;
|
|
144
|
+
const name = typeof me.body.name === "string" ? me.body.name : null;
|
|
145
|
+
const resolved = await resolveWorldId(fetchImpl, doors, request.world.trim());
|
|
146
|
+
if (!resolved.ok)
|
|
147
|
+
return resolved;
|
|
148
|
+
const minted = await mintGrant(fetchImpl, doors, token, resolved.worldId, request.agentActorId);
|
|
149
|
+
if (!minted.ok)
|
|
150
|
+
return minted;
|
|
151
|
+
const { grant } = minted;
|
|
152
|
+
const worldId = grant.worldId ?? resolved.worldId;
|
|
153
|
+
const doorOverride = nonEmpty(deps.env.SPAWN_DOOR_ORIGIN);
|
|
154
|
+
const doorOrigin = (grant.attachUrl ? originOf(grant.attachUrl) : null) ?? (doors.attach ? originOf(doors.attach) : null) ?? (doorOverride ? originOf(doorOverride) : null);
|
|
155
|
+
if (!doorOrigin)
|
|
156
|
+
return { ok: false, verdict: `the grant names no session door for ${origin} (no attachUrl, no doors.play.attach) \u2014 pass --door <the kernel origin> (SPAWN_DOOR_ORIGIN), e.g. --door http://localhost:8787 on a dev stack` };
|
|
157
|
+
const engineOverride = nonEmpty(deps.env.SPAWN_ENGINE)?.toLowerCase();
|
|
158
|
+
let engineHash = null;
|
|
159
|
+
let engineSource = null;
|
|
160
|
+
if (engineOverride) {
|
|
161
|
+
if (!ENGINE_HASH_RE.test(engineOverride))
|
|
162
|
+
return { ok: false, verdict: `SPAWN_ENGINE / --engine must be the 32-hex engine hash, got "${engineOverride}"` };
|
|
163
|
+
engineHash = engineOverride;
|
|
164
|
+
engineSource = "override";
|
|
165
|
+
} else if (grant.engineHash) {
|
|
166
|
+
engineHash = grant.engineHash;
|
|
167
|
+
engineSource = "grant";
|
|
168
|
+
} else {
|
|
169
|
+
const discovered = await discoverEngineHash(fetchImpl, doorOrigin, worldId, grant.grantWire);
|
|
170
|
+
if (discovered) {
|
|
171
|
+
engineHash = discovered.hash;
|
|
172
|
+
engineSource = discovered.source;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!engineHash || !engineSource)
|
|
176
|
+
return { ok: false, verdict: `could not learn the engine build at ${doorOrigin} (the grant names none, the session shell names no /v4/client/<hash>/, no build-stamp) \u2014 pass --engine <32-hex hash> (SPAWN_ENGINE)` };
|
|
177
|
+
const http = asHttp(doorOrigin);
|
|
178
|
+
const env = {
|
|
179
|
+
SPAWN_TOKEN: grant.grantWire,
|
|
180
|
+
...grant.presentationWire ? { SPAWN_PRESENTATION_CARD: grant.presentationWire } : {},
|
|
181
|
+
ROOM_HOST_VARIANT_ID: worldId,
|
|
182
|
+
ROOM_HOST_EDGE_ORIGIN: http,
|
|
183
|
+
ROOM_HOST_KILN_ORIGIN: origin,
|
|
184
|
+
ROOM_HOST_ASSET_ORIGIN: origin,
|
|
185
|
+
ROOM_HOST_ENGINE_VERSION: engineHash,
|
|
186
|
+
...grant.engineSemver ? { ROOM_HOST_ENGINE_SEMVER: grant.engineSemver } : {},
|
|
187
|
+
ROOM_HOST_ENGINE_BASE_URL: http
|
|
188
|
+
};
|
|
189
|
+
return { ok: true, env, worldId, worldAddress: resolved.address, doorOrigin, engineHash, engineSource, handle, name };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ../../apps/cf-kernel/src/_entry/headless-room-host/session/shell-client.ts
|
|
193
|
+
class ShellUnreachableError extends Error {
|
|
194
|
+
constructor(detail) {
|
|
195
|
+
super(`session shell unreachable: ${detail}`);
|
|
196
|
+
this.name = "ShellUnreachableError";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function requestJson(options, path, init, timeoutMs) {
|
|
200
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
201
|
+
const controller = new AbortController;
|
|
202
|
+
const budget = timeoutMs ?? options.timeoutMs ?? 1e4;
|
|
203
|
+
const timer = setTimeout(() => controller.abort(), budget);
|
|
204
|
+
let response;
|
|
205
|
+
try {
|
|
206
|
+
response = await doFetch(new URL(path, options.baseUrl), { ...init, signal: controller.signal });
|
|
207
|
+
} catch (error) {
|
|
208
|
+
throw new ShellUnreachableError(error instanceof Error ? error.message : String(error));
|
|
209
|
+
} finally {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
}
|
|
212
|
+
const body = await response.json().catch(() => null);
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
const detail = typeof body === "object" && body !== null && "error" in body && typeof body.error === "string" ? body.error : `HTTP ${response.status}`;
|
|
215
|
+
throw new Error(`${path} \u2192 ${detail}`);
|
|
216
|
+
}
|
|
217
|
+
return body;
|
|
218
|
+
}
|
|
219
|
+
function shellHealth(options) {
|
|
220
|
+
return requestJson(options, "/healthz");
|
|
221
|
+
}
|
|
222
|
+
function shellWhere(options) {
|
|
223
|
+
return requestJson(options, "/where");
|
|
224
|
+
}
|
|
225
|
+
function shellSceneSnapshot(options) {
|
|
226
|
+
return requestJson(options, "/scene-snapshot");
|
|
227
|
+
}
|
|
228
|
+
function shellCrossing(options) {
|
|
229
|
+
return requestJson(options, "/crossing");
|
|
230
|
+
}
|
|
231
|
+
function shellCrossingPrefetch(options, args) {
|
|
232
|
+
return requestJson(options, "/crossing/prefetch", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(args) });
|
|
233
|
+
}
|
|
234
|
+
function shellMove(options, args) {
|
|
235
|
+
const budget = (Math.min(120, Math.max(1, args.timeoutS ?? 30)) + 15) * 1000;
|
|
236
|
+
return requestJson(options, "/move", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(args) }, budget);
|
|
237
|
+
}
|
|
238
|
+
function shellLook(options, args) {
|
|
239
|
+
return requestJson(options, "/look", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(args) }, 20000);
|
|
240
|
+
}
|
|
241
|
+
function shellPlayers(options) {
|
|
242
|
+
return requestJson(options, "/players");
|
|
243
|
+
}
|
|
244
|
+
function shellInputs(options) {
|
|
245
|
+
return requestJson(options, "/inputs");
|
|
246
|
+
}
|
|
247
|
+
function shellRun(options, args) {
|
|
248
|
+
const budget = (Math.min(3600, Math.max(1, args.timeoutSec ?? 300)) + 30) * 1000;
|
|
249
|
+
return requestJson(options, "/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(args) }, budget);
|
|
250
|
+
}
|
|
251
|
+
function shellDepart(options, reason) {
|
|
252
|
+
return requestJson(options, "/depart", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ reason }) });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ../../apps/cf-kernel/src/_entry/headless-room-host/session/registry.ts
|
|
256
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
257
|
+
import { tmpdir } from "os";
|
|
258
|
+
import path from "path";
|
|
259
|
+
function sessionDir(env) {
|
|
260
|
+
const explicit = env.SPAWN_CLIENT_SESSION_DIR?.trim();
|
|
261
|
+
if (explicit)
|
|
262
|
+
return explicit;
|
|
263
|
+
const home = env.HOME?.trim();
|
|
264
|
+
return home ? path.join(home, ".spawn", "client-sessions") : path.join(tmpdir(), "spawn-client-sessions");
|
|
265
|
+
}
|
|
266
|
+
var registryPath = (dir) => path.join(dir, "registry.json");
|
|
267
|
+
function sessionLogPath(dir, name) {
|
|
268
|
+
return path.join(dir, "logs", `${name}.log`);
|
|
269
|
+
}
|
|
270
|
+
function isSessionRecord(value) {
|
|
271
|
+
if (typeof value !== "object" || value === null)
|
|
272
|
+
return false;
|
|
273
|
+
return typeof Reflect.get(value, "name") === "string" && typeof Reflect.get(value, "pid") === "number" && typeof Reflect.get(value, "httpPort") === "number" && typeof Reflect.get(value, "roomId") === "string";
|
|
274
|
+
}
|
|
275
|
+
function loadRegistry(dir) {
|
|
276
|
+
const file = registryPath(dir);
|
|
277
|
+
if (!existsSync(file))
|
|
278
|
+
return [];
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
281
|
+
if (!Array.isArray(parsed))
|
|
282
|
+
return [];
|
|
283
|
+
return parsed.filter(isSessionRecord);
|
|
284
|
+
} catch {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function saveRegistry(dir, records) {
|
|
289
|
+
mkdirSync(dir, { recursive: true });
|
|
290
|
+
const file = registryPath(dir);
|
|
291
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
292
|
+
writeFileSync(tmp, `${JSON.stringify(records, null, 2)}
|
|
293
|
+
`);
|
|
294
|
+
renameSync(tmp, file);
|
|
295
|
+
}
|
|
296
|
+
function upsertSession(dir, record) {
|
|
297
|
+
const records = loadRegistry(dir).filter((existing) => existing.name !== record.name);
|
|
298
|
+
records.push(record);
|
|
299
|
+
saveRegistry(dir, records);
|
|
300
|
+
}
|
|
301
|
+
function removeSession(dir, name) {
|
|
302
|
+
saveRegistry(dir, loadRegistry(dir).filter((existing) => existing.name !== name));
|
|
303
|
+
}
|
|
304
|
+
function pidAlive(pid, kill = process.kill.bind(process)) {
|
|
305
|
+
try {
|
|
306
|
+
kill(pid, 0);
|
|
307
|
+
return true;
|
|
308
|
+
} catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function loadAndReap(dir, isAlive = pidAlive) {
|
|
313
|
+
const records = loadRegistry(dir);
|
|
314
|
+
const live = records.filter((record) => isAlive(record.pid));
|
|
315
|
+
const reaped = records.filter((record) => !isAlive(record.pid));
|
|
316
|
+
if (reaped.length > 0)
|
|
317
|
+
saveRegistry(dir, live);
|
|
318
|
+
return { live, reaped };
|
|
319
|
+
}
|
|
320
|
+
function resolveSession(records, name) {
|
|
321
|
+
if (name) {
|
|
322
|
+
const found = records.find((record) => record.name === name);
|
|
323
|
+
return found ?? `no session named "${name}" (live: ${records.map((record) => record.name).join(", ") || "none"})`;
|
|
324
|
+
}
|
|
325
|
+
if (records.length === 0)
|
|
326
|
+
return "no live client sessions \u2014 start one with `spawn client join`";
|
|
327
|
+
if (records.length > 1)
|
|
328
|
+
return `multiple live sessions (${records.map((record) => record.name).join(", ")}) \u2014 pass --name`;
|
|
329
|
+
return records[0];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ../../apps/cf-kernel/src/_entry/headless-room-host/session/launch.ts
|
|
333
|
+
var DEFAULT_TTL_S = 900;
|
|
334
|
+
var DEFAULT_READY_TIMEOUT_MS = 90000;
|
|
335
|
+
function resolvePlayerName(as) {
|
|
336
|
+
const raw = as?.trim();
|
|
337
|
+
if (!raw || raw.toLowerCase() === "probe")
|
|
338
|
+
return "SPAWN_PROBE (client)";
|
|
339
|
+
if (raw.toLowerCase() === "savi")
|
|
340
|
+
return "Savi (client)";
|
|
341
|
+
if (raw.startsWith("SPAWN_PROBE") || raw.startsWith("Savi"))
|
|
342
|
+
return raw;
|
|
343
|
+
return `${raw} (probe)`;
|
|
344
|
+
}
|
|
345
|
+
var REQUIRED_ENV = ["ROOM_HOST_EDGE_ORIGIN", "ROOM_HOST_KILN_ORIGIN", "ROOM_HOST_VARIANT_ID", "SPAWN_AI_PLAYER_APP_ID", "SPAWN_AI_PLAYER_TOKEN_MINT_BEARER"];
|
|
346
|
+
var REQUIRED_SESSION_ENV = ["ROOM_HOST_EDGE_ORIGIN", "ROOM_HOST_KILN_ORIGIN", "ROOM_HOST_VARIANT_ID"];
|
|
347
|
+
var isSessionLaneEnv = (env) => Boolean(env.SPAWN_TOKEN?.trim()) || Boolean(env.SPAWN_WORKSHOP_API?.trim() && env.SPAWN_WORKSHOP_TOKEN?.trim() && env.ROOM_HOST_VARIANT_ID?.trim());
|
|
348
|
+
function missingJoinEnv(env) {
|
|
349
|
+
const missing = (isSessionLaneEnv(env) ? REQUIRED_SESSION_ENV : REQUIRED_ENV).filter((name) => !env[name]?.trim());
|
|
350
|
+
if (!env.ROOM_HOST_WORKER_ENTRY?.trim() && !env.ROOM_HOST_ENGINE_VERSION?.trim()) {
|
|
351
|
+
missing.push("ROOM_HOST_ENGINE_VERSION (or ROOM_HOST_WORKER_ENTRY for source runs)");
|
|
352
|
+
}
|
|
353
|
+
return missing;
|
|
354
|
+
}
|
|
355
|
+
function defaultShellEntry() {
|
|
356
|
+
return path2.resolve(path2.dirname(fileURLToPath(import.meta.url)), "..", "main.ts");
|
|
357
|
+
}
|
|
358
|
+
function freePort() {
|
|
359
|
+
return new Promise((resolve, reject) => {
|
|
360
|
+
const server = createServer();
|
|
361
|
+
server.once("error", reject);
|
|
362
|
+
server.listen(0, "127.0.0.1", () => {
|
|
363
|
+
const address = server.address();
|
|
364
|
+
const port = typeof address === "object" && address !== null ? address.port : null;
|
|
365
|
+
server.close(() => port !== null ? resolve(port) : reject(new Error("could not allocate a session port")));
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
370
|
+
function logTail(logPath, lines = 12) {
|
|
371
|
+
try {
|
|
372
|
+
const content = readFileSync2(logPath, "utf8");
|
|
373
|
+
return content.split(`
|
|
374
|
+
`).filter(Boolean).slice(-lines).join(`
|
|
375
|
+
`);
|
|
376
|
+
} catch {
|
|
377
|
+
return "(no log written)";
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
var nonEmpty2 = (value) => {
|
|
381
|
+
const trimmed = value?.trim();
|
|
382
|
+
return trimmed !== undefined && trimmed.length > 0 ? trimmed : undefined;
|
|
383
|
+
};
|
|
384
|
+
async function fetchFreshSessionGrant(env, fetchImpl = fetch) {
|
|
385
|
+
const api = nonEmpty2(env.SPAWN_WORKSHOP_API);
|
|
386
|
+
const workshopToken = nonEmpty2(env.SPAWN_WORKSHOP_TOKEN);
|
|
387
|
+
if (!api || !workshopToken)
|
|
388
|
+
return null;
|
|
389
|
+
try {
|
|
390
|
+
const response = await fetchImpl(`${api.replace(/\/+$/, "")}/workshop/io/session-grant`, { headers: { authorization: `Bearer ${workshopToken}` }, signal: AbortSignal.timeout(8000) });
|
|
391
|
+
if (!response.ok)
|
|
392
|
+
return null;
|
|
393
|
+
const body = await response.json().catch(() => null);
|
|
394
|
+
if (!isObject(body) || typeof body.grantWire !== "string" || !body.grantWire)
|
|
395
|
+
return null;
|
|
396
|
+
const engine = isObject(body.engine) ? body.engine : {};
|
|
397
|
+
return {
|
|
398
|
+
grantWire: body.grantWire,
|
|
399
|
+
worldId: typeof body.worldId === "string" && body.worldId ? body.worldId : null,
|
|
400
|
+
engine: { hash: typeof engine.hash === "string" && engine.hash ? engine.hash : null, semver: typeof engine.semver === "string" && engine.semver ? engine.semver : null }
|
|
401
|
+
};
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function buildChildEnv(env, session) {
|
|
407
|
+
const childEnv = {};
|
|
408
|
+
for (const [key, value] of Object.entries(env)) {
|
|
409
|
+
if (value === undefined)
|
|
410
|
+
continue;
|
|
411
|
+
if (key.startsWith("SPAWN_WORKSHOP_") || key.startsWith("SPAWN_CLIENT_") || key.startsWith("SPAWN_BOOTH_"))
|
|
412
|
+
continue;
|
|
413
|
+
if (key.startsWith("ROOM_HOST_") || key.startsWith("SPAWN_") || key === "PATH" || key === "HOME" || key === "TMPDIR")
|
|
414
|
+
childEnv[key] = value;
|
|
415
|
+
}
|
|
416
|
+
childEnv.ROOM_HOST_ROOM_ID = session.roomId;
|
|
417
|
+
childEnv.ROOM_HOST_ROOM_MODE = "dev";
|
|
418
|
+
childEnv.ROOM_HOST_PORT = String(session.port);
|
|
419
|
+
childEnv.ROOM_HOST_BIND = nonEmpty2(env.ROOM_HOST_BIND) ?? "127.0.0.1";
|
|
420
|
+
childEnv.ROOM_HOST_PLAYER_NAME = session.playerName;
|
|
421
|
+
childEnv.ROOM_HOST_TTL_S = String(session.ttlS);
|
|
422
|
+
childEnv.ROOM_HOST_DEPART_ON_FOREIGN_PRESENCE = "false";
|
|
423
|
+
if (session.bodyModelUrl !== undefined)
|
|
424
|
+
childEnv.ROOM_HOST_CHARACTER_MODEL_URL = session.bodyModelUrl;
|
|
425
|
+
if (session.bodyMaterials !== undefined)
|
|
426
|
+
childEnv.ROOM_HOST_CHARACTER_MATERIALS_JSON = JSON.stringify(session.bodyMaterials);
|
|
427
|
+
return childEnv;
|
|
428
|
+
}
|
|
429
|
+
async function waitForConnected(args) {
|
|
430
|
+
const deadline = Date.now() + args.readyBudgetMs;
|
|
431
|
+
let lastHealth = null;
|
|
432
|
+
for (;; ) {
|
|
433
|
+
if (!args.isAlive(args.pid)) {
|
|
434
|
+
throw new Error(`session shell exited before it connected \u2014 last log lines:
|
|
435
|
+
${logTail(args.logPath)}`);
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
lastHealth = await shellHealth({ baseUrl: args.baseUrl, fetchImpl: args.fetchImpl, timeoutMs: 3000 });
|
|
439
|
+
if (lastHealth.phase === "connected" || lastHealth.phase === "hosting")
|
|
440
|
+
return lastHealth;
|
|
441
|
+
if (lastHealth.phase === "terminal")
|
|
442
|
+
throw new Error(`session shell went terminal during boot: ${lastHealth.engineNote ?? "unknown"}`);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
if (error instanceof Error && error.message.includes("terminal"))
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
if (Date.now() >= deadline) {
|
|
448
|
+
throw new Error(`session shell did not reach a connected phase within ${args.readyBudgetMs}ms (last: ${lastHealth?.phase ?? "unreachable"}) \u2014 log tail:
|
|
449
|
+
${logTail(args.logPath)}`);
|
|
450
|
+
}
|
|
451
|
+
await sleep(500);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async function joinSession(deps, options) {
|
|
455
|
+
const world = nonEmpty2(options.world);
|
|
456
|
+
let account = null;
|
|
457
|
+
if (world !== undefined) {
|
|
458
|
+
if (!isAccountToken(deps.env.SPAWN_TOKEN)) {
|
|
459
|
+
throw new Error(`spawn client join ${world}: this box's SPAWN_TOKEN is a session grant for its own world, not an account token \u2014 drop the world to join here, or set SPAWN_TOKEN=sak_\u2026 (your account token) to join ${world}`);
|
|
460
|
+
}
|
|
461
|
+
const resolved = await resolveAccountJoin({ env: deps.env, ...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {} }, { world, ...options.agentActorId !== undefined ? { agentActorId: options.agentActorId } : {} });
|
|
462
|
+
if (!resolved.ok)
|
|
463
|
+
throw new Error(`spawn client join ${world}: ${resolved.verdict}`);
|
|
464
|
+
account = resolved;
|
|
465
|
+
} else if (isAccountToken(deps.env.SPAWN_TOKEN)) {
|
|
466
|
+
throw new Error("spawn client join: SPAWN_TOKEN is your account token \u2014 name the world to play: spawn client join @user/world (or its play URL / uuid)");
|
|
467
|
+
}
|
|
468
|
+
const fresh = account ? null : await fetchFreshSessionGrant(deps.env, deps.fetchImpl);
|
|
469
|
+
const env = account ? { ...deps.env, ...account.env } : fresh ? {
|
|
470
|
+
...deps.env,
|
|
471
|
+
SPAWN_TOKEN: fresh.grantWire,
|
|
472
|
+
...fresh.worldId ? { ROOM_HOST_VARIANT_ID: fresh.worldId } : {},
|
|
473
|
+
...fresh.engine.hash ? { ROOM_HOST_ENGINE_VERSION: fresh.engine.hash } : {},
|
|
474
|
+
...fresh.engine.semver ? { ROOM_HOST_ENGINE_SEMVER: fresh.engine.semver } : {}
|
|
475
|
+
} : deps.env;
|
|
476
|
+
const missing = missingJoinEnv(env);
|
|
477
|
+
if (missing.length > 0) {
|
|
478
|
+
throw new Error(`spawn client join needs env this shell does not carry: ${missing.join(", ")} \u2014 see docs/headless-room-host.md for the ROOM_HOST_* contract`);
|
|
479
|
+
}
|
|
480
|
+
const dir = sessionDir(env);
|
|
481
|
+
const name = nonEmpty2(options.name) ?? "default";
|
|
482
|
+
if (!/^[a-zA-Z0-9._-]{1,64}$/.test(name))
|
|
483
|
+
throw new Error(`session name "${name}" must be 1-64 chars of [a-zA-Z0-9._-]`);
|
|
484
|
+
const isAlive = deps.isAlive ?? pidAlive;
|
|
485
|
+
const existing = loadRegistry(dir).find((record2) => record2.name === name && isAlive(record2.pid));
|
|
486
|
+
if (existing)
|
|
487
|
+
throw new Error(`session "${name}" is already live (pid ${existing.pid}, room ${existing.roomId}) \u2014 leave it first or pass a different --name`);
|
|
488
|
+
const port = await freePort();
|
|
489
|
+
if (isSessionLaneEnv(env) && nonEmpty2(options.roomId) !== undefined) {
|
|
490
|
+
throw new Error("spawn client join: --room does not apply on a 6.0 session (the world-native room IS the world) \u2014 drop the flag");
|
|
491
|
+
}
|
|
492
|
+
const roomId = isSessionLaneEnv(env) ? nonEmpty2(env.ROOM_HOST_VARIANT_ID) ?? "" : nonEmpty2(options.roomId) ?? nonEmpty2(env.ROOM_HOST_ROOM_ID) ?? "room-1";
|
|
493
|
+
const playerName = account ? nonEmpty2(options.as) ?? account.name ?? account.handle ?? "agent" : resolvePlayerName(options.as);
|
|
494
|
+
const ttlS = Math.max(1, Math.floor(options.ttlS ?? DEFAULT_TTL_S));
|
|
495
|
+
mkdirSync2(path2.join(dir, "logs"), { recursive: true });
|
|
496
|
+
const logPath = sessionLogPath(dir, name);
|
|
497
|
+
if (options.bodyMaterials !== undefined && options.bodyModelUrl === undefined) {
|
|
498
|
+
throw new Error("--materials-json dresses a session avatar \u2014 pass --body <model url> alongside it");
|
|
499
|
+
}
|
|
500
|
+
const childEnv = buildChildEnv(env, { port, roomId, playerName, ttlS, ...options.bodyModelUrl !== undefined ? { bodyModelUrl: options.bodyModelUrl } : {}, ...options.bodyMaterials !== undefined ? { bodyMaterials: options.bodyMaterials } : {} });
|
|
501
|
+
const entry = nonEmpty2(env.SPAWN_CLIENT_SHELL_ENTRY) ?? defaultShellEntry();
|
|
502
|
+
if (!entry.startsWith("file:") && !existsSync2(entry)) {
|
|
503
|
+
throw new Error(`session shell entry not found at ${entry} \u2014 set SPAWN_CLIENT_SHELL_ENTRY (a headless-room-host main.ts/main.mjs)`);
|
|
504
|
+
}
|
|
505
|
+
const bunBinary = typeof Bun !== "undefined" ? process.execPath : "bun";
|
|
506
|
+
const logFd = openSync(logPath, "a");
|
|
507
|
+
const spawnImpl = deps.spawnImpl ?? ((cmd, args, opts) => {
|
|
508
|
+
const child = spawn(cmd, args, { env: opts.env, detached: true, stdio: ["ignore", opts.logFd, opts.logFd] });
|
|
509
|
+
child.unref();
|
|
510
|
+
if (child.pid === undefined)
|
|
511
|
+
throw new Error("shell process failed to spawn (no pid)");
|
|
512
|
+
return { pid: child.pid };
|
|
513
|
+
});
|
|
514
|
+
const { pid } = spawnImpl(bunBinary, [entry], { env: childEnv, logFd });
|
|
515
|
+
const startedAt = new Date().toISOString();
|
|
516
|
+
let health;
|
|
517
|
+
try {
|
|
518
|
+
health = await waitForConnected({
|
|
519
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
520
|
+
pid,
|
|
521
|
+
logPath,
|
|
522
|
+
readyBudgetMs: Math.max(5000, options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS),
|
|
523
|
+
isAlive,
|
|
524
|
+
...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}
|
|
525
|
+
});
|
|
526
|
+
} catch (error) {
|
|
527
|
+
if (isAlive(pid)) {
|
|
528
|
+
try {
|
|
529
|
+
(deps.killImpl ?? ((target, signal) => process.kill(target, signal)))(pid, "SIGTERM");
|
|
530
|
+
} catch {}
|
|
531
|
+
}
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
const record = {
|
|
535
|
+
name,
|
|
536
|
+
pid,
|
|
537
|
+
httpPort: port,
|
|
538
|
+
roomId,
|
|
539
|
+
variantId: env.ROOM_HOST_VARIANT_ID?.trim() ?? "",
|
|
540
|
+
appId: env.SPAWN_AI_PLAYER_APP_ID?.trim() ?? env.SPAWN_APP_ID?.trim() ?? "",
|
|
541
|
+
playerName: health.playerName,
|
|
542
|
+
clientId: health.clientId,
|
|
543
|
+
entityId: health.selfEntityId,
|
|
544
|
+
ttlDeadlineMs: health.ttlDeadlineMs,
|
|
545
|
+
startedAt,
|
|
546
|
+
logPath,
|
|
547
|
+
...account ? { worldAddress: account.worldAddress, handle: account.handle, doorOrigin: account.doorOrigin } : {}
|
|
548
|
+
};
|
|
549
|
+
upsertSession(dir, record);
|
|
550
|
+
return record;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ../../apps/cf-kernel/src/_entry/headless-room-host/session/screenshot.ts
|
|
554
|
+
import { writeFileSync as writeFileSync2 } from "fs";
|
|
555
|
+
|
|
556
|
+
// ../../apps/cf-kernel/src/engine/library/math/vec.ts
|
|
557
|
+
function forwardFromYawPitch(yawRadians, pitchRadians) {
|
|
558
|
+
const cosPitch = Math.cos(pitchRadians);
|
|
559
|
+
return [-Math.sin(yawRadians) * cosPitch, Math.sin(pitchRadians), -Math.cos(yawRadians) * cosPitch];
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ../../apps/cf-kernel/src/_entry/headless-room-host/session/screenshot.ts
|
|
563
|
+
var EYE_HEIGHT_M = 1.6;
|
|
564
|
+
function viewpointCameraFor(where) {
|
|
565
|
+
if (!where.position)
|
|
566
|
+
return null;
|
|
567
|
+
const yaw = where.cameraYaw ?? 0;
|
|
568
|
+
const pitch = where.cameraPitch ?? 0;
|
|
569
|
+
const eye = [where.position.x, where.position.y + EYE_HEIGHT_M, where.position.z];
|
|
570
|
+
const forward = forwardFromYawPitch(yaw, pitch);
|
|
571
|
+
const reach = 6;
|
|
572
|
+
return { position: eye, target: [eye[0] + forward[0] * reach, eye[1] + forward[1] * reach, eye[2] + forward[2] * reach] };
|
|
573
|
+
}
|
|
574
|
+
async function tryClientCapture(env, session, fetchImpl) {
|
|
575
|
+
const apiOrigin = env.SPAWN_WORKSHOP_API?.trim();
|
|
576
|
+
const token = env.SPAWN_WORKSHOP_TOKEN?.trim();
|
|
577
|
+
if (!apiOrigin || !token)
|
|
578
|
+
return null;
|
|
579
|
+
let camera = null;
|
|
580
|
+
try {
|
|
581
|
+
camera = viewpointCameraFor(await shellWhere({ baseUrl: `http://127.0.0.1:${session.httpPort}`, fetchImpl }));
|
|
582
|
+
} catch {
|
|
583
|
+
camera = null;
|
|
584
|
+
}
|
|
585
|
+
let response;
|
|
586
|
+
try {
|
|
587
|
+
response = await fetchImpl(`${apiOrigin.replace(/\/+$/, "")}/workshop/io/capture`, {
|
|
588
|
+
method: "POST",
|
|
589
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
590
|
+
body: JSON.stringify(camera ? { camera } : {})
|
|
591
|
+
});
|
|
592
|
+
} catch (error) {
|
|
593
|
+
return { failed: `workshop capture door unreachable: ${error instanceof Error ? error.message : String(error)}` };
|
|
594
|
+
}
|
|
595
|
+
const body = await response.json().catch(() => null);
|
|
596
|
+
const answer = typeof body === "object" && body !== null ? body : {};
|
|
597
|
+
if (!response.ok || answer.ok !== true || typeof answer.dataUrl !== "string") {
|
|
598
|
+
return { failed: `no connected player client could capture (${answer.message ?? answer.error ?? `HTTP ${response.status}`})` };
|
|
599
|
+
}
|
|
600
|
+
return { dataUrl: answer.dataUrl, ...answer.note ? { note: answer.note } : {}, viewpoint: camera !== null };
|
|
601
|
+
}
|
|
602
|
+
async function tryBoothCapture(env, session, fetchImpl) {
|
|
603
|
+
const boothUrl = env.SPAWN_BOOTH_URL?.trim();
|
|
604
|
+
if (!boothUrl)
|
|
605
|
+
return null;
|
|
606
|
+
const secret = env.SPAWN_BOOTH_SECRET?.trim();
|
|
607
|
+
let response;
|
|
608
|
+
try {
|
|
609
|
+
response = await fetchImpl(new URL("/capture", boothUrl), {
|
|
610
|
+
method: "POST",
|
|
611
|
+
headers: { "content-type": "application/json", ...secret ? { authorization: `Bearer ${secret}` } : {} },
|
|
612
|
+
body: JSON.stringify({ appId: session.appId, requestor: "system", timeoutMs: 45000 })
|
|
613
|
+
});
|
|
614
|
+
} catch (error) {
|
|
615
|
+
return { failed: `booth unreachable: ${error instanceof Error ? error.message : String(error)}` };
|
|
616
|
+
}
|
|
617
|
+
if (!response.ok)
|
|
618
|
+
return { failed: `booth transport error HTTP ${response.status}` };
|
|
619
|
+
const body = await response.json().catch(() => null);
|
|
620
|
+
const answer = typeof body === "object" && body !== null ? body : {};
|
|
621
|
+
if (answer.ok !== true || typeof answer.dataUrl !== "string") {
|
|
622
|
+
return { failed: `booth capture failed: ${answer.reason ?? "unknown"}${answer.note ? ` \u2014 ${answer.note}` : ""}` };
|
|
623
|
+
}
|
|
624
|
+
return { dataUrl: answer.dataUrl };
|
|
625
|
+
}
|
|
626
|
+
function writeDataUrl(dataUrl, outPath) {
|
|
627
|
+
const comma = dataUrl.indexOf(",");
|
|
628
|
+
const payload = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
|
|
629
|
+
writeFileSync2(outPath, Buffer.from(payload, "base64"));
|
|
630
|
+
}
|
|
631
|
+
async function captureScreenshot(env, session, outPath, fetchImpl = fetch) {
|
|
632
|
+
const degraded = [];
|
|
633
|
+
const client = await tryClientCapture(env, session, fetchImpl);
|
|
634
|
+
if (client && "dataUrl" in client) {
|
|
635
|
+
writeDataUrl(client.dataUrl, outPath);
|
|
636
|
+
return {
|
|
637
|
+
tier: "client-pixels",
|
|
638
|
+
label: client.viewpoint ? "real rendered pixels \u2014 this session's viewpoint, drawn by a connected player's client" : "real rendered pixels \u2014 a connected player's own viewport (the session had no pose yet)",
|
|
639
|
+
outPath,
|
|
640
|
+
...client.note ? { note: client.note } : {}
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
if (client && "failed" in client)
|
|
644
|
+
degraded.push(client.failed);
|
|
645
|
+
const booth = await tryBoothCapture(env, session, fetchImpl);
|
|
646
|
+
if (booth && "dataUrl" in booth) {
|
|
647
|
+
writeDataUrl(booth.dataUrl, outPath);
|
|
648
|
+
return { tier: "booth-pixels", label: "real rendered pixels via the render booth (its own headless client eye \u2014 not this session's viewpoint)", outPath, ...degraded.length > 0 ? { note: `degraded past a player's client: ${degraded.join("; ")}` } : {} };
|
|
649
|
+
}
|
|
650
|
+
if (booth && "failed" in booth)
|
|
651
|
+
degraded.push(booth.failed);
|
|
652
|
+
const snapshot = await shellSceneSnapshot({ baseUrl: `http://127.0.0.1:${session.httpPort}`, fetchImpl });
|
|
653
|
+
const simPath = outPath.endsWith(".json") ? outPath : `${outPath}.sim-only.json`;
|
|
654
|
+
writeFileSync2(simPath, `${JSON.stringify(snapshot, null, 2)}
|
|
655
|
+
`);
|
|
656
|
+
return {
|
|
657
|
+
tier: "sim-only",
|
|
658
|
+
label: snapshot.label,
|
|
659
|
+
outPath: simPath,
|
|
660
|
+
note: degraded.length > 0 ? `degraded to sim-only: ${degraded.join("; ")}` : "no connected player client, no render booth (SPAWN_BOOTH_URL) \u2014 sim-state snapshot, no pixels"
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ../../apps/cf-kernel/src/_entry/headless-room-host/session/index.ts
|
|
665
|
+
var sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
666
|
+
var LEAVE_EXIT_WAIT_MS = 15000;
|
|
667
|
+
function createClientSessionLib(env, fetchImpl = fetch) {
|
|
668
|
+
const dir = sessionDir(env);
|
|
669
|
+
const resolveLive = (name) => {
|
|
670
|
+
const { live } = loadAndReap(dir);
|
|
671
|
+
const resolved = resolveSession(live, name);
|
|
672
|
+
if (typeof resolved === "string")
|
|
673
|
+
throw new Error(resolved);
|
|
674
|
+
return resolved;
|
|
675
|
+
};
|
|
676
|
+
const clientFor = (record) => ({ baseUrl: `http://127.0.0.1:${record.httpPort}`, fetchImpl });
|
|
677
|
+
return {
|
|
678
|
+
join: (options) => joinSession({ env, fetchImpl }, options),
|
|
679
|
+
status: async () => {
|
|
680
|
+
const { live, reaped } = loadAndReap(dir);
|
|
681
|
+
const rows = [];
|
|
682
|
+
for (const record of live) {
|
|
683
|
+
let health = null;
|
|
684
|
+
try {
|
|
685
|
+
health = await shellHealth(clientFor(record));
|
|
686
|
+
} catch {}
|
|
687
|
+
const ttlDeadlineMs = health?.ttlDeadlineMs ?? null;
|
|
688
|
+
rows.push({
|
|
689
|
+
...record,
|
|
690
|
+
entityId: health?.selfEntityId ?? record.entityId,
|
|
691
|
+
alive: true,
|
|
692
|
+
phase: health?.phase ?? null,
|
|
693
|
+
connected: health?.connected ?? null,
|
|
694
|
+
worldTick: health?.worldTick ?? null,
|
|
695
|
+
ttlRemainingS: ttlDeadlineMs !== null ? Math.max(0, Math.round((ttlDeadlineMs - Date.now()) / 1000)) : null,
|
|
696
|
+
reaped: false
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
return { sessions: rows, reaped: reaped.map((record) => `${record.name} (pid ${record.pid} gone \u2014 registry entry reaped)`) };
|
|
700
|
+
},
|
|
701
|
+
where: async (name) => shellWhere(clientFor(resolveLive(name))),
|
|
702
|
+
move: async (args, name) => shellMove(clientFor(resolveLive(name)), args),
|
|
703
|
+
look: async (args, name) => shellLook(clientFor(resolveLive(name)), args),
|
|
704
|
+
screenshot: async (outPath, name) => captureScreenshot(env, resolveLive(name), outPath, fetchImpl),
|
|
705
|
+
crossing: async (name) => shellCrossing(clientFor(resolveLive(name))),
|
|
706
|
+
prefetch: async (args, name) => shellCrossingPrefetch(clientFor(resolveLive(name)), args),
|
|
707
|
+
players: async (name) => shellPlayers(clientFor(resolveLive(name))),
|
|
708
|
+
inputs: async (name) => shellInputs(clientFor(resolveLive(name))),
|
|
709
|
+
run: async (args, name) => shellRun(clientFor(resolveLive(name)), args),
|
|
710
|
+
leave: async (name) => {
|
|
711
|
+
const record = resolveLive(name);
|
|
712
|
+
let departRequested = false;
|
|
713
|
+
try {
|
|
714
|
+
await shellDepart(clientFor(record), "spawn client leave");
|
|
715
|
+
departRequested = true;
|
|
716
|
+
} catch (error) {
|
|
717
|
+
if (!(error instanceof ShellUnreachableError))
|
|
718
|
+
throw error;
|
|
719
|
+
try {
|
|
720
|
+
process.kill(record.pid, "SIGTERM");
|
|
721
|
+
departRequested = true;
|
|
722
|
+
} catch {}
|
|
723
|
+
}
|
|
724
|
+
const deadline = Date.now() + LEAVE_EXIT_WAIT_MS;
|
|
725
|
+
let exited = !pidAlive(record.pid);
|
|
726
|
+
while (!exited && Date.now() < deadline) {
|
|
727
|
+
await sleep2(400);
|
|
728
|
+
exited = !pidAlive(record.pid);
|
|
729
|
+
}
|
|
730
|
+
if (exited)
|
|
731
|
+
removeSession(dir, record.name);
|
|
732
|
+
return {
|
|
733
|
+
name: record.name,
|
|
734
|
+
departed: departRequested,
|
|
735
|
+
exited,
|
|
736
|
+
note: exited ? "session departed and exited (graceful path: drain \u2192 journaled despawn where the tier supports it \u2192 lifecycle-hook settle \u2192 socket close)" : `depart requested but the process (pid ${record.pid}) is still running after ${LEAVE_EXIT_WAIT_MS}ms \u2014 check ${record.logPath}`
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
export {
|
|
742
|
+
shellWhere,
|
|
743
|
+
shellSceneSnapshot,
|
|
744
|
+
shellRun,
|
|
745
|
+
shellPlayers,
|
|
746
|
+
shellMove,
|
|
747
|
+
shellLook,
|
|
748
|
+
shellInputs,
|
|
749
|
+
shellHealth,
|
|
750
|
+
shellDepart,
|
|
751
|
+
shellCrossingPrefetch,
|
|
752
|
+
shellCrossing,
|
|
753
|
+
sessionDir,
|
|
754
|
+
resolveSession,
|
|
755
|
+
resolvePlayerName,
|
|
756
|
+
resolveAccountJoin,
|
|
757
|
+
missingJoinEnv,
|
|
758
|
+
loadAndReap,
|
|
759
|
+
isAccountToken,
|
|
760
|
+
createClientSessionLib,
|
|
761
|
+
ShellUnreachableError,
|
|
762
|
+
DEFAULT_TTL_S,
|
|
763
|
+
DEFAULT_SPAWN_ORIGIN,
|
|
764
|
+
ACCOUNT_TOKEN_PREFIX
|
|
765
|
+
};
|