@homie-rocks/studio 0.30.1 → 0.31.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/CHANGELOG.md +111 -1
- package/README.md +361 -22
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +142 -123
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +127 -8
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/demo.mjs +16 -4
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +116 -5
- package/lib/doctor.mjs +143 -16
- package/lib/feedback.mjs +3 -2
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +36 -35
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +693 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +132 -0
- package/lib/scaffold.mjs +88 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +424 -16
- package/netplay/netplay.ts +756 -40
- package/package.json +10 -1
- package/parts/PARTS.md +163 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +103 -19
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +3 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +13 -6
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +15 -5
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +15 -5
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +141 -23
- package/worker/parts.mjs +234 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- package/lib/remix-assets.mjs +0 -84
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT A DEPLOY KEEPS ON THIS COMPUTER (all under .studio/, which git ignores): the lock that stops two deploys of
|
|
3
|
+
* one studio running at once, and what the last deploy put live (each game's content hash), so the next one can say
|
|
4
|
+
* which games changed.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { hostname } from 'node:os';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { gameDigest } from './build.mjs';
|
|
10
|
+
import { readLocal, writeLocal } from './studio.mjs';
|
|
11
|
+
|
|
12
|
+
/* ------------------------------------------------------------------ one deploy at a time */
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* TWO DEPLOYS OF ONE STUDIO AT ONCE (two chats, or a chat and a terminal) both rewrite wrangler.jsonc and studio.json,
|
|
16
|
+
* both build into site/dist, and both run `wrangler deploy`: the site that goes live is some mix of the two, and
|
|
17
|
+
* neither says so. The lock is one small file, made with the "fail if it exists" flag so two starts cannot both win.
|
|
18
|
+
* It names the process that holds it and when it started, and a lock is STALE, and simply taken over, when that
|
|
19
|
+
* process is gone (a deploy that was killed never removed it) or when it is older than any deploy can run.
|
|
20
|
+
*/
|
|
21
|
+
export const DEPLOY_LOCK = '.studio/deploy.lock';
|
|
22
|
+
/** Older than this, a lock is taken over even if its process id is alive: by then the id belongs to something else. */
|
|
23
|
+
export const LOCK_MAX_AGE_MS = 2 * 60 * 60_000;
|
|
24
|
+
|
|
25
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; } };
|
|
26
|
+
|
|
27
|
+
/** Whether a lock record still holds: its process runs on this computer and it is not older than any deploy. */
|
|
28
|
+
export function lockHolds(rec, { now = Date.now(), host = hostname(), isAlive = alive } = {}) {
|
|
29
|
+
if (!rec || !Number.isInteger(rec.pid) || rec.pid <= 0) return false;
|
|
30
|
+
const age = now - Date.parse(rec.at ?? '');
|
|
31
|
+
if (!Number.isFinite(age) || age > LOCK_MAX_AGE_MS || age < -60_000) return false;
|
|
32
|
+
// A lock written on another computer (a synced folder) cannot be checked by process id: only its age counts.
|
|
33
|
+
return rec.host && rec.host !== host ? true : isAlive(rec.pid);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Take the studio's deploy lock: { ok: true, release } or { ok: false, why, held }. `release` is safe to call twice
|
|
38
|
+
* and removes only this process's own lock.
|
|
39
|
+
*/
|
|
40
|
+
export function lockDeploy(root, { pid = process.pid, now = () => Date.now(), host = hostname(), isAlive = alive } = {}) {
|
|
41
|
+
const file = join(root, DEPLOY_LOCK);
|
|
42
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
43
|
+
const mine = { pid, at: new Date(now()).toISOString(), host };
|
|
44
|
+
for (let tries = 0; tries < 3; tries += 1) {
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(file, `${JSON.stringify(mine)}\n`, { flag: 'wx' });
|
|
47
|
+
let released = false;
|
|
48
|
+
const release = () => {
|
|
49
|
+
if (released) return;
|
|
50
|
+
released = true;
|
|
51
|
+
try { if (JSON.parse(readFileSync(file, 'utf8')).pid === pid) rmSync(file, { force: true }); } catch { /* gone, or not ours */ }
|
|
52
|
+
};
|
|
53
|
+
return { ok: true, release, file: DEPLOY_LOCK };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
56
|
+
}
|
|
57
|
+
let held = null;
|
|
58
|
+
try { held = JSON.parse(readFileSync(file, 'utf8')); } catch { held = null; }
|
|
59
|
+
if (lockHolds(held, { now: now(), host, isAlive })) {
|
|
60
|
+
const mins = Math.max(0, Math.round((now() - Date.parse(held.at)) / 60_000));
|
|
61
|
+
return {
|
|
62
|
+
ok: false, held, needs: 'deploy-running',
|
|
63
|
+
why: `another deploy of this studio is already running (process ${held.pid}${held.host && held.host !== host ? ` on ${held.host}` : ''}, started ${mins < 1 ? 'under a minute' : `${mins} min`} ago), so this one did not start and changed nothing. Two deploys at once would overwrite each other's build and config. Wait for it to end, then deploy again; a deploy that was killed leaves a lock that the next one takes over by itself (${DEPLOY_LOCK} names the process).`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// Stale: the process that wrote it is gone, the file is torn, or it is older than any deploy. Take it over.
|
|
67
|
+
rmSync(file, { force: true });
|
|
68
|
+
}
|
|
69
|
+
return { ok: false, held: null, needs: 'deploy-running', why: `could not take the deploy lock (${DEPLOY_LOCK}): another deploy keeps taking it. Try again in a moment.` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ------------------------------------------------------------------ what went live */
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* ONE HASH VOCABULARY. A game's hash is the BUILD's: the digest `homie-studio build` prints for it, keeps in
|
|
76
|
+
* site/dist/_site/build.json, and that the live site says in /.well-known/homie-studio.json (`games[].build.hash`);
|
|
77
|
+
* `perf` names the build it measured by the same one. A deploy used to compute a second, different hash of the same
|
|
78
|
+
* folder (12 digits, with the landing's pictures in it), so "the hash the deploy printed" never matched "the hash the
|
|
79
|
+
* build printed" or the one the live site answers with, and "is the live game the one I built?" could not be asked
|
|
80
|
+
* of a deploy's own output. It reads the build's now. This is that digest for a game folder the build's report does
|
|
81
|
+
* not name (a site built by an older toolkit): the same function, never a second recipe. Null when the game is not
|
|
82
|
+
* built.
|
|
83
|
+
*/
|
|
84
|
+
export function gameHash(root, id) {
|
|
85
|
+
return gameDigest(join(root, 'site', 'dist', 'games', id));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What the build said it made (site/dist/_site/build.json): { id: { hash, bundle, chunks, changed } }, or {}. */
|
|
89
|
+
export function buildReport(root) {
|
|
90
|
+
try { return JSON.parse(readFileSync(join(root, 'site', 'dist', '_site', 'build.json'), 'utf8')).games ?? {}; } catch { return {}; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The built catalogue's games as a deploy sees them: { id: { hash } }. `hash` is the build's own when the
|
|
95
|
+
* catalogue carries one for the game (a string `hash`), else gameHash.
|
|
96
|
+
*/
|
|
97
|
+
export function builtGames(root, ids) {
|
|
98
|
+
let cat = null;
|
|
99
|
+
try { cat = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')); } catch { cat = null; }
|
|
100
|
+
// The hash is the BUILD's own: from its report (build.json), else the catalogue's `built.hash`, else gameHash,
|
|
101
|
+
// which is the same digest computed here.
|
|
102
|
+
const report = buildReport(root);
|
|
103
|
+
const out = {};
|
|
104
|
+
for (const id of ids) {
|
|
105
|
+
const row = (cat?.games ?? []).find((g) => g.id === id) ?? {};
|
|
106
|
+
const said = report[id]?.hash ?? row.built?.hash;
|
|
107
|
+
const own = typeof said === 'string' && /^[a-f0-9]{6,64}$/i.test(said) ? said.toLowerCase() : null;
|
|
108
|
+
out[id] = { hash: own ?? gameHash(root, id) };
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* This build against what the last deploy from this computer put live: per game 'new' (not deployed from here
|
|
115
|
+
* before), 'changed' or 'unchanged'; and the games that are gone. `first` is true when this computer has no record of
|
|
116
|
+
* a deploy (every game then reads 'new').
|
|
117
|
+
*
|
|
118
|
+
* `withdrawn`: the games the last deploy, made by a toolkit that still had remix, recorded as open to be taken whole
|
|
119
|
+
* (`remix: true` in that record). This deploy serves no game's source, so a directory that listed the studio still
|
|
120
|
+
* holds a stale offer for each until it reads the site again (lib/cloudflare.mjs asks it to, once: the record this
|
|
121
|
+
* deploy writes has no such field, so the list is empty from then on).
|
|
122
|
+
*/
|
|
123
|
+
export function compareDeploy(now, before, older = before) {
|
|
124
|
+
const prev = before?.games ?? null;
|
|
125
|
+
const games = Object.entries(now).map(([id, g]) => {
|
|
126
|
+
const was = prev?.[id];
|
|
127
|
+
return { id, hash: g.hash, change: !was ? 'new' : was.hash === g.hash ? 'unchanged' : 'changed' };
|
|
128
|
+
});
|
|
129
|
+
const removed = Object.keys(prev ?? {}).filter((id) => !(id in now));
|
|
130
|
+
// `older`: the record as it was written, whatever toolkit wrote it. A record from before deploys read the build's
|
|
131
|
+
// hash is no record for comparing (lastDeploy), yet it is the one that says a game's whole source was offered.
|
|
132
|
+
const offered = older?.games ?? {};
|
|
133
|
+
const withdrawn = Object.keys(offered).filter((id) => offered[id]?.remix === true);
|
|
134
|
+
return { first: !prev, games, removed, withdrawn };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One game's line in a deploy's output, the same words in the terminal, in the deploy's steps and on the chat card:
|
|
139
|
+
* "changed since the last deploy from this computer, build 0123456789abcdef". `change` is compareDeploy's
|
|
140
|
+
* ('new' | 'changed' | 'unchanged'), or the result's 'first deploy from this computer'; a CI deploy has none.
|
|
141
|
+
*/
|
|
142
|
+
export function deployWords(g) {
|
|
143
|
+
const build = g?.hash ? `build ${g.hash}` : 'no files';
|
|
144
|
+
const c = g?.change;
|
|
145
|
+
if (c === 'changed' || c === 'unchanged') return `${c} since the last deploy from this computer, ${build}`;
|
|
146
|
+
if (c === 'new') return `new since the last deploy from this computer, ${build}`;
|
|
147
|
+
return `${c ?? 'built'}, ${build}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/*
|
|
151
|
+
* "CHANGED" IS SAID TWICE AND MEANS TWO THINGS. The build's report says changed since the last BUILD on this
|
|
152
|
+
* computer; a deploy says changed since the last DEPLOY from this computer, from its own record here
|
|
153
|
+
* (.studio/local.json `deployed`), because ten builds may sit between two deploys. Both compare the same hash.
|
|
154
|
+
* A record written before deploys read the build's hash (no `hashes: 'build'`) holds the other recipe's hashes,
|
|
155
|
+
* which compare unequal to everything: it is read as no record, so that one deploy says "first deploy from this
|
|
156
|
+
* computer" instead of calling every game changed.
|
|
157
|
+
*/
|
|
158
|
+
/** What the last deploy from this computer put live ({ at, hashes, games }), or null. */
|
|
159
|
+
/** The last deploy's record exactly as written, by this toolkit or an older one (see compareDeploy's `older`). */
|
|
160
|
+
export function lastDeployRecord(root) { return readLocal(root).deployed ?? null; }
|
|
161
|
+
export function lastDeploy(root) {
|
|
162
|
+
const rec = readLocal(root).deployed ?? null;
|
|
163
|
+
return rec?.hashes === 'build' ? rec : null;
|
|
164
|
+
}
|
|
165
|
+
/** Record what this deploy put live, on this computer only. */
|
|
166
|
+
export function recordDeploy(root, now) { writeLocal(root, { deployed: { at: new Date().toISOString(), hashes: 'build', games: now } }); }
|
package/lib/dev.mjs
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio dev` and `dev --stop`: the whole site on this computer (pages, rooms in a local Durable Object, D1),
|
|
3
|
+
* and exactly this studio's dev server stopped, never another project's.
|
|
4
|
+
*
|
|
5
|
+
* THE REGISTRATION FILE. `dev` records itself in <worker folder>/.wrangler/homie-dev.json (the worker folder is where
|
|
6
|
+
* wrangler.jsonc is: the studio's root, or site/ in a studio made before 0.10.0). It is the whole contract between
|
|
7
|
+
* a running dev server and `dev --stop`, the MCP tools `preview_run` / `preview_stop`, and a studio's own wrapper
|
|
8
|
+
* script that wants to be stopped the same way:
|
|
9
|
+
*
|
|
10
|
+
* { "pid": 4242, the process that owns the server (homie-studio dev, or a wrapper); stopped second
|
|
11
|
+
* "child": 4243, the Wrangler it started; stopped first (optional: a wrapper with one process leaves it out)
|
|
12
|
+
* "port": 8787, the local port; what "is it running" and the Play addresses are read from
|
|
13
|
+
* "at": "2026-10-06T12:00:00.000Z" } when it started (ISO 8601)
|
|
14
|
+
*
|
|
15
|
+
* One JSON object on one line; unknown keys are ignored. `dev --stop` signals a process the file names ONLY when it
|
|
16
|
+
* is still this studio's: it is alive, and its command line names the studio's folder, or its working directory is
|
|
17
|
+
* inside the studio's folder, or it is still the kind of program the key says (`pid`: homie-studio; `child`: wrangler
|
|
18
|
+
* or workerd). A recycled process id is none of those, so nothing else on the computer is ever signalled. A wrapper
|
|
19
|
+
* that is started from the studio's folder therefore qualifies as it is; it should delete the file when it ends.
|
|
20
|
+
*
|
|
21
|
+
* STALE STATE HEALS ITSELF. A server stopped another way (its terminal closed, a kill by process id or by port)
|
|
22
|
+
* leaves the file behind, and sometimes a runtime still listening on the port. Both `dev` and `dev --stop` look at
|
|
23
|
+
* what is really there: a record whose processes are gone is removed and said to be stale; a listener left on the
|
|
24
|
+
* recorded port is stopped only when it provably runs from this studio's folder; a port held by anything else is
|
|
25
|
+
* named and left alone.
|
|
26
|
+
*/
|
|
27
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { connect } from 'node:net';
|
|
30
|
+
import { dirname, join, sep } from 'node:path';
|
|
31
|
+
import { detectLocalAi, localAiVars } from './local-ai.mjs';
|
|
32
|
+
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
33
|
+
import { routesOf } from './routes.mjs';
|
|
34
|
+
import { ensureMigrations, migrationWord } from './scaffold.mjs';
|
|
35
|
+
import { listGames, workerDir } from './studio.mjs';
|
|
36
|
+
|
|
37
|
+
export const devFile = (root) => join(workerDir(root), '.wrangler', 'homie-dev.json');
|
|
38
|
+
|
|
39
|
+
/** The registration file's record, or null (no file, or a torn one). */
|
|
40
|
+
export function readDevRecord(root) {
|
|
41
|
+
try { const rec = JSON.parse(readFileSync(devFile(root), 'utf8')); return rec && typeof rec === 'object' ? rec : null; } catch { return null; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; } };
|
|
45
|
+
const realOf = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
46
|
+
const inside = (dir, root) => Boolean(dir) && (dir === root || dir.startsWith(root.endsWith(sep) ? root : `${root}${sep}`));
|
|
47
|
+
|
|
48
|
+
/** A process's command line (`ps`), or null when it cannot be read. */
|
|
49
|
+
function commandOf(pid) {
|
|
50
|
+
const ps = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' });
|
|
51
|
+
return ps.status === 0 ? ps.stdout : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A process's working directory (Linux: /proc; macOS: lsof), or null. */
|
|
55
|
+
export function cwdOf(pid) {
|
|
56
|
+
try { return readlinkSync(`/proc/${pid}/cwd`); } catch { /* not Linux */ }
|
|
57
|
+
const r = spawnSync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { encoding: 'utf8', timeout: 4000 });
|
|
58
|
+
if (r.status !== 0) return null;
|
|
59
|
+
return /^n(.+)$/m.exec(r.stdout ?? '')?.[1] ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether a process provably runs from this studio: its command line names the studio's folder, or its working
|
|
64
|
+
* directory is inside it. The strict test: what a process the registration file does NOT name must pass before it
|
|
65
|
+
* is stopped (a runtime left listening on the recorded port).
|
|
66
|
+
*/
|
|
67
|
+
export function runsFromStudio(pid, root) {
|
|
68
|
+
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid || !isAlive(pid)) return false;
|
|
69
|
+
const real = realOf(root);
|
|
70
|
+
const cmd = commandOf(pid) ?? '';
|
|
71
|
+
if (cmd.includes(root) || cmd.includes(real)) return true;
|
|
72
|
+
const cwd = cwdOf(pid);
|
|
73
|
+
return inside(cwd, root) || inside(cwd ? realOf(cwd) : null, real);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether a process id the registration file names is still this studio's (see the header): alive, and from this
|
|
78
|
+
* studio's folder or still the kind of program its key says. A recycled id is neither.
|
|
79
|
+
*/
|
|
80
|
+
export function processOfStudio(pid, root, kind) {
|
|
81
|
+
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid || !isAlive(pid)) return false;
|
|
82
|
+
const cmd = commandOf(pid);
|
|
83
|
+
if (cmd === null) return process.platform === 'win32'; // no ps: the registration file is all there is
|
|
84
|
+
if (kind === 'dev' ? /homie-studio/.test(cmd) : /wrangler|workerd/.test(cmd)) return true;
|
|
85
|
+
return runsFromStudio(pid, root);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Whether something accepts connections on a local port. */
|
|
89
|
+
export function portOpen(port, host = '127.0.0.1', timeout = 800) {
|
|
90
|
+
return new Promise((done) => {
|
|
91
|
+
const s = connect({ port: Number(port), host });
|
|
92
|
+
const end = (v) => { s.destroy(); done(v); };
|
|
93
|
+
s.setTimeout(timeout, () => end(false));
|
|
94
|
+
s.on('connect', () => end(true));
|
|
95
|
+
s.on('error', () => end(false));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The process ids listening on a local TCP port (lsof, else Linux's ss; an empty list where neither can say). */
|
|
100
|
+
export function listenersOn(port) {
|
|
101
|
+
const ids = (list) => [...new Set(list.map(Number).filter((n) => Number.isInteger(n) && n > 1))];
|
|
102
|
+
const r = spawnSync('lsof', ['-nP', `-iTCP:${Number(port)}`, '-sTCP:LISTEN', '-t'], { encoding: 'utf8', timeout: 4000 });
|
|
103
|
+
if (r.status === 0) return ids(String(r.stdout ?? '').split(/\s+/));
|
|
104
|
+
const ss = spawnSync('ss', ['-Hltnp', `sport = :${Number(port)}`], { encoding: 'utf8', timeout: 4000 });
|
|
105
|
+
if (ss.status === 0) return ids([...String(ss.stdout ?? '').matchAll(/pid=(\d+)/g)].map((m) => m[1]));
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What is really there for this studio's dev server:
|
|
111
|
+
* state 'none' no registration file
|
|
112
|
+
* state 'running' the file names at least one process that is still this studio's
|
|
113
|
+
* state 'stale' the file is there and none of its processes is this studio's any more (or it is torn)
|
|
114
|
+
* `pids` are the ones `dev --stop` would signal; `named` every id the file gives, with why one does not count.
|
|
115
|
+
*/
|
|
116
|
+
export function devState(root) {
|
|
117
|
+
const file = devFile(root);
|
|
118
|
+
if (!existsSync(file)) return { state: 'none', file, rec: null, pids: [], named: [], port: null };
|
|
119
|
+
const rec = readDevRecord(root);
|
|
120
|
+
const named = [[rec?.child, 'wrangler'], [rec?.pid, 'dev']].filter(([p]) => Number.isInteger(p)).map(([pid, kind]) => {
|
|
121
|
+
const ours = processOfStudio(pid, root, kind);
|
|
122
|
+
return { pid, kind, ours, alive: ours || isAlive(pid) };
|
|
123
|
+
});
|
|
124
|
+
const pids = named.filter((n) => n.ours).map((n) => n.pid);
|
|
125
|
+
const port = Number.isInteger(rec?.port) ? rec.port : null;
|
|
126
|
+
return { state: pids.length ? 'running' : 'stale', file, rec, pids, named, port };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** This studio's running dev server as { url, port, pid }, or null: what `preview_run` reuses (a stale record is not one). */
|
|
130
|
+
export function runningDev(root) {
|
|
131
|
+
const st = devState(root);
|
|
132
|
+
if (st.state !== 'running' || !st.port) return null;
|
|
133
|
+
return { url: `http://127.0.0.1:${st.port}`, port: st.port, pid: st.rec.pid ?? st.pids[0] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function endAll(pids, ms = 8000) {
|
|
137
|
+
for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ } }
|
|
138
|
+
const deadline = Date.now() + ms;
|
|
139
|
+
const left = () => pids.filter(isAlive);
|
|
140
|
+
while (left().length && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
|
|
141
|
+
for (const pid of left()) { try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A runtime left listening on the dev port by a server that was stopped another way: stopped only when it runs
|
|
146
|
+
* from this studio's folder. Returns { stopped: [pid], foreign: [pid] }; `foreign` holds the port and is not ours.
|
|
147
|
+
*/
|
|
148
|
+
export async function clearLeftover(root, port) {
|
|
149
|
+
if (!port || !(await portOpen(port))) return { stopped: [], foreign: [], open: false };
|
|
150
|
+
const pids = listenersOn(port);
|
|
151
|
+
const ours = pids.filter((p) => runsFromStudio(p, root));
|
|
152
|
+
if (ours.length) await endAll(ours);
|
|
153
|
+
return { stopped: ours, foreign: pids.filter((p) => !ours.includes(p)), open: ours.length ? await portOpen(port) : true };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `dev --stop`: this studio's dev server only; a stale record is cleaned up and said to be one. */
|
|
157
|
+
export async function stopDev(root) {
|
|
158
|
+
const st = devState(root);
|
|
159
|
+
if (st.state === 'none') return { ok: true, command: 'dev stop', stopped: [], why: 'no dev server of this studio is running' };
|
|
160
|
+
if (st.pids.length) await endAll(st.pids);
|
|
161
|
+
rmSync(st.file, { force: true });
|
|
162
|
+
// What a kill by process id or by port leaves: the runtime still on the port. Ours is stopped; anything else is named.
|
|
163
|
+
const left = await clearLeftover(root, st.port);
|
|
164
|
+
const stopped = [...st.pids, ...left.stopped];
|
|
165
|
+
const notes = [];
|
|
166
|
+
if (!st.pids.length) notes.push(st.rec ? 'its record was stale (the server had been stopped another way) and was removed' : 'its record was unreadable and was removed');
|
|
167
|
+
const strangers = st.named.filter((n) => !n.ours && n.alive);
|
|
168
|
+
if (strangers.length) notes.push(`process ${strangers.map((n) => n.pid).join(', ')} named in the record is running but is not this studio's (its command line and working directory are elsewhere), so it was left alone`);
|
|
169
|
+
if (left.open && !left.stopped.length && st.port) notes.push(`something still answers on port ${st.port}${left.foreign.length ? ` (process ${left.foreign.join(', ')})` : ''} that is not this studio's, so it was left alone`);
|
|
170
|
+
return { ok: true, command: 'dev stop', stopped, ...(st.pids.length ? {} : { stale: true }), ...(notes.length ? { why: notes.join('; '), notes } : {}) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/*
|
|
174
|
+
* THE CONFIG WRANGLER RUNS LOCALLY. Two things in a studio's wrangler.jsonc must not reach `wrangler dev`:
|
|
175
|
+
*
|
|
176
|
+
* - ROUTES. With a route or a custom domain in its config, Wrangler serves the local Worker AS that production
|
|
177
|
+
* hostname: the request's host is the live domain, and the game page hands the game a room socket there
|
|
178
|
+
* (`wss://<the live domain>/...`). An unpublished game then finds no room and plays alone; a published one
|
|
179
|
+
* silently joins its LIVE relay, so a "local" two-browser test was never local. Local dev has no use for a
|
|
180
|
+
* route, so `routes`, `route` and the zone keys are left out of what it runs.
|
|
181
|
+
* - THE AI BINDING, unless --remote-ai asked for it: AI guides think scripted (or with Clef here) for free.
|
|
182
|
+
*
|
|
183
|
+
* When either differs from the file, dev runs from a copy in .wrangler/ (absolute paths, the same local state),
|
|
184
|
+
* and wrangler.jsonc itself is never changed by dev. A fresh studio's config needs neither, and runs as it is.
|
|
185
|
+
*/
|
|
186
|
+
const ROUTE_KEYS = ['routes', 'route', 'zone_id', 'zone_name'];
|
|
187
|
+
|
|
188
|
+
export function devConfig(root, remoteAi) {
|
|
189
|
+
const base = workerDir(root);
|
|
190
|
+
const file = join(base, 'wrangler.jsonc');
|
|
191
|
+
let json;
|
|
192
|
+
try { json = JSON.parse(readFileSync(file, 'utf8').replace(/^\s*\/\/.*$/gm, '')); } catch { return { args: [], stripped: [] }; }
|
|
193
|
+
const stripped = routesOf(json).map((r) => r.pattern);
|
|
194
|
+
const routed = ROUTE_KEYS.some((k) => k in json);
|
|
195
|
+
const aiNote = remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : null;
|
|
196
|
+
if (Boolean(json.ai) === remoteAi && !routed) return { args: [], note: aiNote, stripped: [] };
|
|
197
|
+
const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
|
|
198
|
+
delete json.$schema;
|
|
199
|
+
json.main = abs(json.main);
|
|
200
|
+
if (json.assets?.directory) json.assets.directory = abs(json.assets.directory);
|
|
201
|
+
json.d1_databases = (json.d1_databases ?? []).map((d) => ({ ...d, ...(d.migrations_dir ? { migrations_dir: abs(d.migrations_dir) } : {}) }));
|
|
202
|
+
for (const k of ROUTE_KEYS) delete json[k];
|
|
203
|
+
const aiChanged = Boolean(json.ai) !== remoteAi;
|
|
204
|
+
if (remoteAi) json.ai = { binding: 'AI', remote: true }; else delete json.ai;
|
|
205
|
+
const copy = join(base, '.wrangler', 'homie-dev.wrangler.json');
|
|
206
|
+
mkdirSync(dirname(copy), { recursive: true });
|
|
207
|
+
writeFileSync(copy, `${JSON.stringify(json, null, 2)}\n`);
|
|
208
|
+
const notes = [
|
|
209
|
+
...(routed ? [`Local dev leaves out the production route${stripped.length === 1 ? '' : 's'} in wrangler.jsonc (${stripped.join(', ') || 'route keys'}): rooms here use this computer's own address, never the live site's. wrangler.jsonc is unchanged.`] : []),
|
|
210
|
+
...(aiChanged ? [remoteAi ? aiNote : 'No Workers AI under dev (--remote-ai for the real one, billed): AI guides think with Clef on this computer when Ollama has it, else scripted.'] : aiNote ? [aiNote] : []),
|
|
211
|
+
];
|
|
212
|
+
return { args: ['--config', copy, '--persist-to', join(base, '.wrangler', 'state')], note: notes.join('\n') || null, stripped, copy };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The room socket address a game page hands its game (`window.HOMIE_NET.url`), or null when the page has none. */
|
|
216
|
+
export function socketUrlIn(html) {
|
|
217
|
+
const m = /window\.HOMIE_NET=(\{.*?\})<\/script>/s.exec(String(html ?? ''));
|
|
218
|
+
if (!m) return null;
|
|
219
|
+
try { return String(JSON.parse(m[1]).url ?? '') || null; } catch { return null; }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* LOCAL ROOMS MUST BE LOCAL. Read one game's page from the local site as a browser would and compare the room socket
|
|
224
|
+
* it is handed with the address it was read from, host AND port. Null when they match (or there is nothing to
|
|
225
|
+
* compare: no game, a game behind a door, the site not answering); else what is wrong, as one refusal.
|
|
226
|
+
*/
|
|
227
|
+
export async function socketOriginProblem(origin, game, { fetchFn = globalThis.fetch } = {}) {
|
|
228
|
+
let html;
|
|
229
|
+
try {
|
|
230
|
+
const res = await fetchFn(`${origin}/${game}/__game/?room=homie-dev-check`, { signal: AbortSignal.timeout(8000) });
|
|
231
|
+
if (!res.ok) return null;
|
|
232
|
+
html = await res.text();
|
|
233
|
+
} catch { return null; }
|
|
234
|
+
const ws = socketUrlIn(html);
|
|
235
|
+
if (!ws) return null;
|
|
236
|
+
let got; let want;
|
|
237
|
+
try { got = new URL(ws); want = new URL(origin); } catch { return null; }
|
|
238
|
+
if (got.host === want.host && got.protocol === (want.protocol === 'https:' ? 'wss:' : 'ws:')) return null;
|
|
239
|
+
return {
|
|
240
|
+
socket: `${got.protocol}//${got.host}`, expected: `${want.protocol === 'https:' ? 'wss' : 'ws'}://${want.host}`,
|
|
241
|
+
why: `local rooms are not local: the page ${origin}/${game}/play hands the game a room socket at ${got.protocol}//${got.host}, not at this computer's ${want.host}. A game here would join that other relay, or find none and play alone, so a test here would prove nothing. The dev server was stopped. The usual reason is a hostname set for local dev in wrangler.jsonc (a "dev" section with "host", or a route this version does not know to leave out): take it out (a host without its port is never right for local dev), then start dev again.`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/*
|
|
246
|
+
* RELAY LINES WITH A TIME ON THEM. Wrangler prints a room socket opening (`GET /<game>/__net 101`) and a runtime
|
|
247
|
+
* error (`Uncaught Error: Network connection lost`) with no time and no room, so a clean check that closed its
|
|
248
|
+
* browsers left errors nobody could match to anything. dev puts the wall-clock time in front of the lines about
|
|
249
|
+
* room sockets and errors (every line with --timestamps), so they can be laid beside a check's own receipts, and
|
|
250
|
+
* says once what a connection-loss line can and cannot mean. It changes nothing in the Worker.
|
|
251
|
+
*/
|
|
252
|
+
const RELAY_LINE = /__net\b|__watch\b|Network connection lost|Uncaught|\[ERROR\]|✘/;
|
|
253
|
+
const LOST = /Network connection lost/;
|
|
254
|
+
export const LOST_HINT = ' (homie dev: the local runtime prints "Network connection lost" when a connection to a room ends abruptly: a tab or a check\'s browser closing, a host leaving. It prints the same for a room operation that really failed. The times here are this computer\'s clock: lay them beside the "__net" lines above and the browser\'s own report before calling it either.)';
|
|
255
|
+
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
256
|
+
|
|
257
|
+
export function stampOf(at = new Date()) {
|
|
258
|
+
const p = (n, w = 2) => String(n).padStart(w, '0');
|
|
259
|
+
return `${p(at.getHours())}:${p(at.getMinutes())}:${p(at.getSeconds())}.${p(at.getMilliseconds(), 3)}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** One line of Wrangler's output as dev prints it: [the line(s)], with `state.hinted` remembering the one-time hint. */
|
|
263
|
+
export function relayLines(line, { all = false, at = new Date(), state = {} } = {}) {
|
|
264
|
+
const plain = line.replace(ANSI, '');
|
|
265
|
+
if (!plain.trim()) return [line];
|
|
266
|
+
const about = RELAY_LINE.test(plain);
|
|
267
|
+
const out = [all || about ? `[${stampOf(at)}] ${line}` : line];
|
|
268
|
+
if (LOST.test(plain) && !state.hinted) { state.hinted = true; out.push(LOST_HINT); }
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Pass a child's stream on line by line through relayLines; a last line without a newline is flushed at the end. */
|
|
273
|
+
function passLines(stream, to, opts) {
|
|
274
|
+
let rest = '';
|
|
275
|
+
stream.setEncoding('utf8');
|
|
276
|
+
stream.on('data', (d) => {
|
|
277
|
+
const parts = `${rest}${d}`.split('\n');
|
|
278
|
+
rest = parts.pop();
|
|
279
|
+
for (const line of parts) to.write(`${relayLines(line, opts).join('\n')}\n`);
|
|
280
|
+
});
|
|
281
|
+
stream.on('end', () => { if (rest) to.write(relayLines(rest, opts).join('\n')); });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const builtIds = (root) => { try { return JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { return null; } };
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* `homie-studio dev`: build once, then Wrangler's local runtime until it is stopped. Returns the command's result.
|
|
288
|
+
* options: port, remoteAi, localAi (false: --no-local-ai), timestamps (every line of Wrangler's gets a time), log.
|
|
289
|
+
*/
|
|
290
|
+
export async function dev(root, { port: askedPort = 8787, remoteAi = false, localAi = true, timestamps = false, log = () => {}, watchMs = 2000 } = {}) {
|
|
291
|
+
const port = String(askedPort ?? 8787);
|
|
292
|
+
const stop = 'npx --no-install homie-studio dev --stop';
|
|
293
|
+
|
|
294
|
+
// What is really there, before anything is built or started (the header: stale state heals itself).
|
|
295
|
+
const st = devState(root);
|
|
296
|
+
if (st.state === 'running') {
|
|
297
|
+
const at = st.port ? `http://127.0.0.1:${st.port}/` : 'an unknown port';
|
|
298
|
+
return { ok: false, command: 'dev', already: true, ...(st.port ? { url: `http://127.0.0.1:${st.port}` } : {}), why: `this studio's dev server is already running at ${at} (process ${st.pids.join(', ')}). Use that one, or stop it first: ${stop}` };
|
|
299
|
+
}
|
|
300
|
+
if (st.state === 'stale') {
|
|
301
|
+
rmSync(st.file, { force: true });
|
|
302
|
+
log(`Cleared a stale dev record (${st.rec ? 'its server had been stopped another way' : 'it was unreadable'}).`);
|
|
303
|
+
}
|
|
304
|
+
for (const p of new Set([st.port, Number(port)].filter(Boolean))) {
|
|
305
|
+
const left = await clearLeftover(root, p);
|
|
306
|
+
if (left.stopped.length) log(`Stopped a leftover local runtime of this studio on port ${p} (process ${left.stopped.join(', ')}): its dev server had been stopped another way.`);
|
|
307
|
+
if (p === Number(port) && left.open) {
|
|
308
|
+
return { ok: false, command: 'dev', needs: 'port', why: `port ${port} is already in use by another program${left.foreign.length ? ` (process ${left.foreign.join(', ')})` : ''}, and it is not this studio's dev server, so nothing was stopped or started. Use another port: npx --no-install homie-studio dev --port ${Number(port) + 1}` };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The build and Wrangler's own helpers are loaded here, not at the top: the MCP server reads this file for the
|
|
313
|
+
// registration (runningDev) and must not load the bundler to do it.
|
|
314
|
+
const [{ build }, { wranglerBin }] = await Promise.all([import('./build.mjs'), import('./cloudflare.mjs')]);
|
|
315
|
+
const b = await build(root, { log });
|
|
316
|
+
if (!b.catalogue.length && !b.songs.length && !b.videos.length) log('No game yet: the home page says "First game coming soon" until the first one is made.');
|
|
317
|
+
const bin = wranglerBin(root);
|
|
318
|
+
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
319
|
+
const env = { ...process.env, ...projectsCloudflareEnv(root), WRANGLER_SEND_METRICS: 'false', CI: '1', ...(process.stdout.isTTY && !process.env.NO_COLOR ? { FORCE_COLOR: process.env.FORCE_COLOR ?? '1' } : {}) };
|
|
320
|
+
for (const added of ensureMigrations(root)) log(`added ${added} (${migrationWord(added)})`);
|
|
321
|
+
await new Promise((done) => {
|
|
322
|
+
const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
323
|
+
m.on('close', done);
|
|
324
|
+
m.on('error', done);
|
|
325
|
+
});
|
|
326
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
327
|
+
log(`Local site: ${origin}/ (each game: ${origin}/<id>/play — open it in two browsers)`);
|
|
328
|
+
const ai = devConfig(root, remoteAi);
|
|
329
|
+
if (ai.note) log(ai.note);
|
|
330
|
+
// The person's own Clef (0.24.4, lib/local-ai.mjs): with Ollama and clef-flash on this computer, the local Worker's
|
|
331
|
+
// guides, chat review and game decisions think here, free. Never a download: dev only says what one would cost.
|
|
332
|
+
let localVars = [];
|
|
333
|
+
if (!remoteAi && localAi) {
|
|
334
|
+
const found = await detectLocalAi();
|
|
335
|
+
localVars = localAiVars(found);
|
|
336
|
+
log(found.ok ? `AI guides, chat review and game decisions think with ${found.model} on this computer (Ollama ${found.version}): free, nothing sent to Cloudflare.` : found.say);
|
|
337
|
+
}
|
|
338
|
+
// --remote-ai: Wrangler's --local turns every remote binding off ("not supported"), so a dev with the real Workers AI
|
|
339
|
+
// (the guides' brains, room chat's review) runs without it; everything else stays local all the same.
|
|
340
|
+
const args = ['dev', ...(remoteAi ? [] : ['--local']), '--ip', '127.0.0.1', '--port', port, ...ai.args, ...localVars];
|
|
341
|
+
log(`Stop it with: ${stop} (this studio's dev server only)`);
|
|
342
|
+
|
|
343
|
+
let child = null;
|
|
344
|
+
let ending = false;
|
|
345
|
+
let restart = null;
|
|
346
|
+
let failed = null;
|
|
347
|
+
const lineState = {};
|
|
348
|
+
// Stopping this process stops Wrangler with it, so no dev server is left running on its own.
|
|
349
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => { ending = true; try { child?.kill(signal); } catch { /* gone */ } });
|
|
350
|
+
|
|
351
|
+
/*
|
|
352
|
+
* A GAME ADDED WHILE DEV RUNS. dev serves what `build` wrote into site/dist. Two things made a new game answer
|
|
353
|
+
* 404 on a server that was already up: it was not built yet (dev builds once, at its start), or it was built and
|
|
354
|
+
* Wrangler's watch of the site's files had not followed the rebuild (a build replaces site/dist). So dev looks every
|
|
355
|
+
* couple of seconds: a game folder that is not built is named, once, with the command; a built game the running
|
|
356
|
+
* site answers 404 for is picked up by restarting Wrangler (this process stays, the address stays; rooms that
|
|
357
|
+
* were open reconnect), once per game.
|
|
358
|
+
*/
|
|
359
|
+
const startedWith = new Set(builtIds(root) ?? []);
|
|
360
|
+
const told = new Set();
|
|
361
|
+
const strikes = new Map();
|
|
362
|
+
const restarted = new Set();
|
|
363
|
+
let up = false;
|
|
364
|
+
let busy = false;
|
|
365
|
+
const look = async () => {
|
|
366
|
+
if (busy || ending || restart || !up) return;
|
|
367
|
+
busy = true;
|
|
368
|
+
try {
|
|
369
|
+
const built = builtIds(root);
|
|
370
|
+
if (!built) return; // mid-build: site/dist is being written
|
|
371
|
+
for (const g of listGames(root)) {
|
|
372
|
+
if (built.includes(g.id) || told.has(g.id)) continue;
|
|
373
|
+
told.add(g.id);
|
|
374
|
+
log(`games/${g.id} is new and not built yet, so ${origin}/${g.id}/play is a 404 for now. Build it (npx --no-install homie-studio build): this dev server serves what build wrote and picks the new game up by itself.`);
|
|
375
|
+
}
|
|
376
|
+
for (const id of built) {
|
|
377
|
+
if (startedWith.has(id)) continue;
|
|
378
|
+
let status = 0;
|
|
379
|
+
try { status = (await fetch(`${origin}/${id}/play`, { redirect: 'manual', signal: AbortSignal.timeout(4000) })).status; } catch { continue; }
|
|
380
|
+
if (status !== 404) { log(`New game ${id} is served here: ${origin}/${id}/play`); startedWith.add(id); strikes.delete(id); continue; }
|
|
381
|
+
const n = (strikes.get(id) ?? 0) + 1;
|
|
382
|
+
strikes.set(id, n);
|
|
383
|
+
if (n < 3) continue;
|
|
384
|
+
if (restarted.has(id)) { startedWith.add(id); log(`${origin}/${id}/play is still a 404 after a restart: check games/${id}/game.json (its "id") and what the build printed for it.`); continue; }
|
|
385
|
+
restarted.add(id);
|
|
386
|
+
strikes.delete(id);
|
|
387
|
+
log(`New game ${id} is built but the running site answers 404 for it: restarting the local site to pick it up (same address; open rooms reconnect).`);
|
|
388
|
+
restart = id;
|
|
389
|
+
try { child?.kill('SIGTERM'); } catch { /* gone */ }
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
} finally { busy = false; }
|
|
393
|
+
};
|
|
394
|
+
const timer = setInterval(() => { look().catch(() => {}); }, watchMs);
|
|
395
|
+
timer.unref?.();
|
|
396
|
+
|
|
397
|
+
/** Wait for the local site, then hold it to its own address (socketOriginProblem); a mismatch ends dev. */
|
|
398
|
+
const settle = async () => {
|
|
399
|
+
const until = Date.now() + 90_000;
|
|
400
|
+
while (Date.now() < until && !ending && child?.exitCode === null) {
|
|
401
|
+
try { if ((await fetch(`${origin}/api/games`, { signal: AbortSignal.timeout(1500) })).ok) { up = true; break; } } catch { /* not yet */ }
|
|
402
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
403
|
+
}
|
|
404
|
+
if (!up) return;
|
|
405
|
+
const game = (builtIds(root) ?? [])[0];
|
|
406
|
+
if (!game) return;
|
|
407
|
+
const bad = await socketOriginProblem(origin, game);
|
|
408
|
+
if (!bad) { log(`Rooms here are local: games open their room sockets at ws://127.0.0.1:${port}.`); return; }
|
|
409
|
+
failed = bad;
|
|
410
|
+
ending = true;
|
|
411
|
+
try { child?.kill('SIGTERM'); } catch { /* gone */ }
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
for (;;) {
|
|
415
|
+
up = false;
|
|
416
|
+
child = spawn(bin, args, { cwd: workerDir(root), env, stdio: ['inherit', 'pipe', 'pipe'] });
|
|
417
|
+
passLines(child.stdout, process.stdout, { all: timestamps, state: lineState });
|
|
418
|
+
passLines(child.stderr, process.stderr, { all: timestamps, state: lineState });
|
|
419
|
+
mkdirSync(dirname(devFile(root)), { recursive: true });
|
|
420
|
+
writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
|
|
421
|
+
settle().catch(() => {});
|
|
422
|
+
await new Promise((done) => { child.on('close', done); child.on('error', done); });
|
|
423
|
+
if (!restart || ending) break;
|
|
424
|
+
restart = null;
|
|
425
|
+
}
|
|
426
|
+
clearInterval(timer);
|
|
427
|
+
rmSync(devFile(root), { force: true });
|
|
428
|
+
if (failed) return { ok: false, command: 'dev', needs: 'local-rooms', socket: failed.socket, expected: failed.expected, why: failed.why };
|
|
429
|
+
return { ok: true, command: 'dev', stopped: true };
|
|
430
|
+
}
|