@homie-rocks/studio 0.30.2 → 0.31.1
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 +108 -1
- package/README.md +349 -23
- 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 +204 -124
- 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 +203 -10
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +172 -9
- package/lib/doctor.mjs +12 -11
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +42 -37
- 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 +720 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +240 -0
- package/lib/scaffold.mjs +83 -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 +552 -16
- package/netplay/netplay.ts +950 -43
- package/package.json +10 -1
- package/parts/PARTS.md +169 -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 +329 -40
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +7 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +41 -12
- 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 +50 -11
- 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 +50 -11
- 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 +156 -29
- package/worker/parts.mjs +275 -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
package/lib/routes.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ROUTES IN A STUDIO'S WRANGLER CONFIG: WHICH ARE THE STUDIO'S, AND WHICH ARE NOT ITS TO TOUCH.
|
|
3
|
+
*
|
|
4
|
+
* A studio on a custom domain shares that domain's Cloudflare zone with whatever else its owner runs there, and a
|
|
5
|
+
* zone's Worker routes are one list for all of it. Three things went wrong with that:
|
|
6
|
+
*
|
|
7
|
+
* 1. `deploy` writes wrangler.jsonc again from studio.json, and it used to write it without `routes`. A studio whose
|
|
8
|
+
* owner had added the exact-host route its domain needed lost it at the next deploy, and the domain went back to
|
|
9
|
+
* whichever Worker the zone's wildcard route names.
|
|
10
|
+
* 2. A zone that already has a wildcard route (`*example.com/*`, the router of another site) answers the studio's
|
|
11
|
+
* hostname FIRST: a route is matched before a custom domain. The studio deployed fine and its own domain showed
|
|
12
|
+
* somebody else's "not found". Nothing said why, or that one exact-host route fixes it.
|
|
13
|
+
* 3. A config that itself names a catch-all (`*` + `/*`) hands every hostname of the zone to the studio's Worker
|
|
14
|
+
* the moment it deploys: another site on the zone stops answering.
|
|
15
|
+
*
|
|
16
|
+
* So: the routes the owner wrote for the studio's OWN hostnames (a custom domain, an exact host) are kept across
|
|
17
|
+
* every rewrite; a wildcard or catch-all route is never deployed by the studio unless studio.json says the zone is
|
|
18
|
+
* the studio's (`cloudflare.allowWildcardRoutes: true`); and a deploy whose domain answers as something else says
|
|
19
|
+
* which route is needed.
|
|
20
|
+
*
|
|
21
|
+
* 4. The zone ALREADY has another site's catch-all or wildcard route, and the studio's custom domain sits beside
|
|
22
|
+
* it. The studio's own config is clean, so nothing above speaks; the route answers the studio's hostname first,
|
|
23
|
+
* and the tempting repair (edit or remove that route) takes the other site down. So a deploy and its plan READ
|
|
24
|
+
* the zone's Worker routes when the studio has a custom domain (readZoneRoutes: two GETs with the sign-in
|
|
25
|
+
* Wrangler already has, never a write) and say, before anything is deployed, that the route is another site's,
|
|
26
|
+
* must not be edited or removed, and which one line makes the studio's hostname its own (zoneFinding). A list
|
|
27
|
+
* that cannot be read is said as unmeasured, never as fine.
|
|
28
|
+
*
|
|
29
|
+
* The studio never changes or removes a route it did not make: a zone's other routes belong to other Workers.
|
|
30
|
+
*/
|
|
31
|
+
import { readFileSync } from 'node:fs';
|
|
32
|
+
import { configPath } from './studio.mjs';
|
|
33
|
+
|
|
34
|
+
/** wrangler.jsonc as an object (whole-line comments left out, as the toolkit writes it), or null. */
|
|
35
|
+
export function readConfig(root) {
|
|
36
|
+
try { return JSON.parse(readFileSync(configPath(root), 'utf8').replace(/^\s*\/\/.*$/gm, '')); } catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Wrangler takes a route as a string or an object, in `routes` or the older single `route`: one shape for all. */
|
|
40
|
+
export function routesOf(config) {
|
|
41
|
+
const raw = [...(Array.isArray(config?.routes) ? config.routes : []), ...(config?.route ? [config.route] : [])];
|
|
42
|
+
return raw.map((r) => (typeof r === 'string' ? { pattern: r } : r)).filter((r) => r && typeof r.pattern === 'string' && r.pattern.trim());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The hostname part of a route's pattern ("play.example.com/*" gives "play.example.com"; a scheme is left out). */
|
|
46
|
+
export function routeHost(route) {
|
|
47
|
+
return String(route?.pattern ?? '').trim().replace(/^[a-z*]+:\/\//i, '').split('/')[0].toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What kind of route it is:
|
|
52
|
+
* 'custom-domain' `custom_domain: true`: Cloudflare makes the DNS record and the certificate for that one host
|
|
53
|
+
* 'exact-host' one hostname, any path ("play.example.com/*"): only that host's requests
|
|
54
|
+
* 'catch-all' every hostname of the zone ("*" + "/*")
|
|
55
|
+
* 'wildcard' a `*` in the hostname ("*example.com/*", "*.example.com/*"): many hosts, maybe not the studio's
|
|
56
|
+
*/
|
|
57
|
+
export function routeKind(route) {
|
|
58
|
+
if (route?.custom_domain === true) return 'custom-domain';
|
|
59
|
+
const host = routeHost(route);
|
|
60
|
+
if (host === '*' || host === '') return 'catch-all';
|
|
61
|
+
return host.includes('*') ? 'wildcard' : 'exact-host';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether a route's hostname pattern covers a hostname ("*example.com" covers "play.example.com" and "example.com"). */
|
|
65
|
+
export function routeCovers(route, hostname) {
|
|
66
|
+
const pattern = routeHost(route);
|
|
67
|
+
if (!pattern) return false;
|
|
68
|
+
const re = new RegExp(`^${pattern.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`);
|
|
69
|
+
return re.test(String(hostname ?? '').toLowerCase());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The config's routes, sorted into the studio's own (kept across every rewrite of wrangler.jsonc, exactly as written)
|
|
74
|
+
* and the wide ones (a wildcard or a catch-all).
|
|
75
|
+
*/
|
|
76
|
+
export function sortRoutes(config) {
|
|
77
|
+
const own = [];
|
|
78
|
+
const wide = [];
|
|
79
|
+
for (const r of routesOf(config)) (['custom-domain', 'exact-host'].includes(routeKind(r)) ? own : wide).push(r);
|
|
80
|
+
return { own, wide };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The routes a rewrite of wrangler.jsonc carries over from the one on disk: the studio's own, and the wide ones
|
|
85
|
+
* only when studio.json says the whole zone is the studio's. Null when there are none, so a studio without routes
|
|
86
|
+
* gets byte for byte the config it always got.
|
|
87
|
+
*/
|
|
88
|
+
export function keptRoutes(root, studio = null) {
|
|
89
|
+
const { own, wide } = sortRoutes(readConfig(root));
|
|
90
|
+
const kept = [...own, ...(studio?.cloudflare?.allowWildcardRoutes === true ? wide : [])];
|
|
91
|
+
return kept.length ? kept : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Why a deploy of this config would reach past the studio, or null. A wide route in the studio's own config makes
|
|
96
|
+
* its Worker answer for hostnames the studio does not have: said before anything is created or deployed.
|
|
97
|
+
*/
|
|
98
|
+
export function wideRouteRefusal(config, studio = null) {
|
|
99
|
+
if (studio?.cloudflare?.allowWildcardRoutes === true) return null;
|
|
100
|
+
const { own, wide } = sortRoutes(config);
|
|
101
|
+
if (!wide.length) return null;
|
|
102
|
+
const names = wide.map((r) => `"${r.pattern}"`).join(', ');
|
|
103
|
+
const all = wide.some((r) => routeKind(r) === 'catch-all');
|
|
104
|
+
const keep = own.length ? ` Its own route${own.length === 1 ? '' : 's'} (${own.map((r) => `"${r.pattern}"`).join(', ')}) stay${own.length === 1 ? 's' : ''}, and every deploy keeps ${own.length === 1 ? 'it' : 'them'}.` : '';
|
|
105
|
+
return {
|
|
106
|
+
needs: 'cloudflare-routes', routes: wide.map((r) => r.pattern),
|
|
107
|
+
why: `wrangler.jsonc gives this studio's Worker the route${wide.length === 1 ? '' : 's'} ${names}: ${all ? 'a catch-all, which answers for EVERY hostname of the zone' : 'a wildcard, which answers for more hostnames than the studio\'s own'}. Deploying it would take those hostnames away from whatever serves them now (another site on the same domain stops answering), so nothing was deployed or changed on Cloudflare. Take ${wide.length === 1 ? 'that route' : 'those routes'} out of "routes" in wrangler.jsonc: a studio needs only its own hostname, as { "pattern": "<its hostname>", "custom_domain": true } or { "pattern": "<its hostname>/*", "zone_name": "<the domain>" }.${keep} If the whole zone really is this studio's, say so in studio.json ("cloudflare": { "allowWildcardRoutes": true }) and deploy again.`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The apex a hostname most likely belongs to ("play.example.com" gives "example.com"): a suggestion, never a lookup. */
|
|
112
|
+
export function likelyZone(hostname) {
|
|
113
|
+
const parts = String(hostname ?? '').toLowerCase().split('.').filter(Boolean);
|
|
114
|
+
return parts.slice(-2).join('.');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The exact-host route that makes a zone's Worker routes send one hostname to this studio's Worker. */
|
|
118
|
+
export function exactRouteFor(hostname, zone = likelyZone(hostname)) {
|
|
119
|
+
return { pattern: `${String(hostname).toLowerCase()}/*`, zone_name: zone };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The studio's custom domain answered, but not as this studio. With no exact-host route for that hostname in the
|
|
124
|
+
* config, the likeliest reason is another Worker's route on the zone that covers it (a wildcard, or a catch-all):
|
|
125
|
+
* a route is matched before a custom domain. Says which route is needed and that the other one is left alone; with
|
|
126
|
+
* the exact-host route already there, says to wait and what to look at instead.
|
|
127
|
+
*/
|
|
128
|
+
export function shadowedDomain(config, hostname, answer = {}) {
|
|
129
|
+
const host = String(hostname ?? '').toLowerCase();
|
|
130
|
+
const own = sortRoutes(config).own;
|
|
131
|
+
const exact = own.find((r) => routeKind(r) === 'exact-host' && routeHost(r) === host);
|
|
132
|
+
const said = answer.status ? `answered ${answer.status}${answer.said ? ` ("${answer.said}")` : ''}` : 'answered';
|
|
133
|
+
if (exact) {
|
|
134
|
+
return { shadowed: false, why: `https://${host} ${said}, not as this studio, although wrangler.jsonc has its exact-host route ("${exact.pattern}"). A new route can take a minute to apply; if it stays like this, look at the zone's Workers Routes in the Cloudflare dashboard for a more specific route on that hostname.` };
|
|
135
|
+
}
|
|
136
|
+
const route = exactRouteFor(host);
|
|
137
|
+
return {
|
|
138
|
+
shadowed: true, route,
|
|
139
|
+
why: `https://${host} ${said}, not as this studio: something else on the domain answers that hostname first. The usual reason is another Worker's route on the same zone that covers it (a wildcard like "*${route.zone_name}/*", or a catch-all): Cloudflare matches a route before a custom domain. The studio does not touch that route (it is not the studio's). Add the studio's own exact-host route to "routes" in wrangler.jsonc, which wins over a wildcard for this one hostname, and deploy again: ${JSON.stringify(route)} (zone_name is the domain as it is named in Cloudflare). Every deploy keeps it.`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ------------------------------------------------------------------ the zone's routes, beside the studio's domain */
|
|
144
|
+
|
|
145
|
+
const CF_API = 'https://api.cloudflare.com/client/v4';
|
|
146
|
+
|
|
147
|
+
/** The zones a hostname may be in, likeliest first: the ones the config's routes name, then each parent of the hostname. */
|
|
148
|
+
export function zoneCandidates(hostname, config = null) {
|
|
149
|
+
const parts = String(hostname ?? '').toLowerCase().split('.').filter(Boolean);
|
|
150
|
+
const named = routesOf(config).map((r) => String(r.zone_name ?? '').toLowerCase()).filter(Boolean);
|
|
151
|
+
const parents = [];
|
|
152
|
+
for (let i = 0; i <= parts.length - 2; i += 1) parents.push(parts.slice(i).join('.'));
|
|
153
|
+
return [...new Set([...named, ...parents])].slice(0, 6);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The Worker routes of the zone a hostname is in, READ from Cloudflare (GET /zones?name=…, then GET
|
|
158
|
+
* /zones/<id>/workers/routes) with headers the toolkit already has (lib/brain-probe.mjs cloudflareAuth: Wrangler's
|
|
159
|
+
* own sign-in). { read: true, zone: { id, name }, routes: [{ pattern, script }] }, or { read: false, why } when it
|
|
160
|
+
* could not be read (no sign-in, a sign-in that may not read the zone, no such zone on it, no network). It never
|
|
161
|
+
* writes. `fetchFn` stands in for the network in tests.
|
|
162
|
+
*/
|
|
163
|
+
export async function readZoneRoutes({ hostname, config = null, headers = null, fetchFn = globalThis.fetch, base = CF_API, ms = 10_000 } = {}) {
|
|
164
|
+
if (!headers) return { read: false, why: 'this computer has no Cloudflare sign-in to read them with' };
|
|
165
|
+
const root = String(base).replace(/\/+$/, '');
|
|
166
|
+
const get = async (path) => {
|
|
167
|
+
const res = await fetchFn(`${root}${path}`, { headers: { ...headers, accept: 'application/json', 'user-agent': 'homie-studio-deploy' }, signal: AbortSignal.timeout(ms) });
|
|
168
|
+
let body = null;
|
|
169
|
+
try { body = await res.json(); } catch { body = null; }
|
|
170
|
+
return { status: res.status, ok: res.ok && body?.success !== false, result: Array.isArray(body?.result) ? body.result : null };
|
|
171
|
+
};
|
|
172
|
+
const refused = (status) => (status === 401 || status === 403 ? 'this Cloudflare sign-in may not read the domain\'s routes' : `Cloudflare answered ${status}`);
|
|
173
|
+
try {
|
|
174
|
+
let zone = null;
|
|
175
|
+
for (const name of zoneCandidates(hostname, config)) {
|
|
176
|
+
const r = await get(`/zones?name=${encodeURIComponent(name)}`);
|
|
177
|
+
if (!r.ok || !r.result) return { read: false, why: refused(r.status) };
|
|
178
|
+
const hit = r.result.find((z) => String(z?.name ?? '').toLowerCase() === name && z?.id);
|
|
179
|
+
if (hit) { zone = { id: String(hit.id), name }; break; }
|
|
180
|
+
}
|
|
181
|
+
if (!zone) return { read: false, why: `no domain that ${String(hostname).toLowerCase()} belongs to is on the Cloudflare account this computer is signed in to` };
|
|
182
|
+
const r = await get(`/zones/${encodeURIComponent(zone.id)}/workers/routes`);
|
|
183
|
+
if (!r.ok || !r.result) return { read: false, zone, why: refused(r.status) };
|
|
184
|
+
return { read: true, zone, routes: r.result.filter((x) => typeof x?.pattern === 'string' && x.pattern.trim()).map((x) => ({ pattern: x.pattern.trim(), script: x.script ? String(x.script) : null })) };
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return { read: false, why: error?.name === 'TimeoutError' ? `Cloudflare did not answer in ${Math.round(ms / 1000)} s` : `Cloudflare could not be reached (${String(error?.cause?.code ?? error?.code ?? error?.message ?? error).slice(0, 80)})` };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const NOT_OURS = 'Never edit or remove a route this studio did not make';
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* What the zone's routes mean for a studio on a custom domain, in words for the plan, the terminal and the tool
|
|
194
|
+
* result. `zone` is readZoneRoutes' answer; `worker` the studio's own Worker; `config` its wrangler.jsonc.
|
|
195
|
+
* state 'clear' no other route covers the studio's hostname: nothing to say (`why` is null)
|
|
196
|
+
* state 'foreign' another site's wildcard or catch-all covers it and the studio has no exact-host route of its
|
|
197
|
+
* own: `warning`, with the one line to add (`route`, `line`)
|
|
198
|
+
* state 'own-route' another site's route covers it, and the studio's own exact-host route already wins for its
|
|
199
|
+
* hostname: `why` says to leave the other route alone
|
|
200
|
+
* state 'taken' the studio's own hostname is routed to another Worker: only the person can decide that
|
|
201
|
+
* state 'unmeasured' the list could not be read: said as that, with the reason
|
|
202
|
+
*/
|
|
203
|
+
export function zoneFinding({ hostname, worker = null, config = null, zone = null } = {}) {
|
|
204
|
+
const host = String(hostname ?? '').toLowerCase();
|
|
205
|
+
const guess = zone?.zone?.name ?? likelyZone(host);
|
|
206
|
+
const route = exactRouteFor(host, guess);
|
|
207
|
+
const line = JSON.stringify(route);
|
|
208
|
+
const ownInConfig = sortRoutes(config).own.find((r) => routeKind(r) === 'exact-host' && routeHost(r) === host) ?? null;
|
|
209
|
+
if (!zone?.read) {
|
|
210
|
+
return {
|
|
211
|
+
state: 'unmeasured', host, zone: zone?.zone?.name ?? null, foreign: [], route, line,
|
|
212
|
+
why: `Not checked: whether another site's route on the domain covers ${host} (${zone?.why ?? 'not read'}). That is unmeasured, not a pass. If ${host} answers as something else after a deploy, the fix is the studio's own exact-host route in "routes" in wrangler.jsonc, ${line} (zone_name is the domain as Cloudflare names it), which every deploy keeps. ${NOT_OURS}.`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const others = zone.routes.filter((r) => r.script !== worker && routeCovers(r, host)).map((r) => ({ pattern: r.pattern, script: r.script, kind: routeKind(r) }));
|
|
216
|
+
const taken = others.filter((r) => r.kind === 'exact-host');
|
|
217
|
+
const wide = others.filter((r) => r.kind !== 'exact-host');
|
|
218
|
+
const said = (list) => list.map((r) => `"${r.pattern}" (${r.script ? `Worker ${r.script}` : 'no Worker: it turns Workers off for what it covers'})`).join(', ');
|
|
219
|
+
const base = { host, zone: zone.zone.name, route, line };
|
|
220
|
+
if (taken.length) {
|
|
221
|
+
return {
|
|
222
|
+
...base, state: 'taken', foreign: others,
|
|
223
|
+
warning: `${host} itself is routed to another Worker on ${zone.zone.name}: ${said(taken)}. That route is not this studio's, so the studio does not take, edit or remove it, and neither should you: whatever it serves would stop answering. Only the person can decide to hand the hostname over, in the domain's Workers Routes in the Cloudflare dashboard; or give the studio a hostname nothing else uses (studio.json cloudflare.domain).`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (!wide.length) return { ...base, state: 'clear', foreign: [], why: null };
|
|
227
|
+
const ownLive = zone.routes.find((r) => r.script === worker && routeKind(r) === 'exact-host' && routeHost(r) === host) ?? null;
|
|
228
|
+
const all = wide.some((r) => r.kind === 'catch-all');
|
|
229
|
+
const them = `${wide.length === 1 ? 'a route' : 'routes'} that cover${wide.length === 1 ? 's' : ''} ${host} and ${wide.length === 1 ? 'is' : 'are'} not this studio's: ${said(wide)}`;
|
|
230
|
+
if (ownInConfig || ownLive) {
|
|
231
|
+
return {
|
|
232
|
+
...base, state: 'own-route', foreign: wide,
|
|
233
|
+
why: `${zone.zone.name} has ${them}. ${wide.length === 1 ? 'It belongs' : 'They belong'} to another site on this domain and must not be edited or removed. The studio's own exact-host route ("${(ownInConfig ?? ownLive).pattern}") wins over ${wide.length === 1 ? 'it' : 'them'} for this one hostname, and every deploy keeps it: nothing to change.`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
...base, state: 'foreign', foreign: wide,
|
|
238
|
+
warning: `${zone.zone.name} already has ${them}. ${wide.length === 1 ? 'That route belongs' : 'Those routes belong'} to another site on this domain: ${wide.length === 1 ? 'it' : 'they'} must not be edited or removed, here or in the Cloudflare dashboard, or that site stops answering. Cloudflare matches a route before a custom domain, so ${all ? 'the catch-all' : 'the wildcard'} answers ${host} first and the studio's own domain would show the other site. The one safe fix is the studio's own exact-host route, which wins over a wider route for this one hostname only and which every deploy keeps. Add this line to "routes" in wrangler.jsonc and deploy: ${line} (or \`homie-studio deploy --own-route\`, which adds exactly that line and nothing else).`,
|
|
239
|
+
};
|
|
240
|
+
}
|
package/lib/scaffold.mjs
CHANGED
|
@@ -36,6 +36,11 @@ import { themeFile } from './site.mjs';
|
|
|
36
36
|
export const COMPAT_DATE = '2026-06-01';
|
|
37
37
|
/** 4.135.0 or later: Worker Previews (`wrangler preview`, a Durable Object namespace per Preview). */
|
|
38
38
|
export const WRANGLER_VERSION = '4.145.0';
|
|
39
|
+
/**
|
|
40
|
+
* The TypeScript a new studio asks for, so `build --types` (and an editor) has a compiler: the studio's own
|
|
41
|
+
* devDependency, never one of the toolkit's (a studio that never checks types pays nothing for it at runtime).
|
|
42
|
+
*/
|
|
43
|
+
export const TYPESCRIPT_VERSION = '5.9.3';
|
|
39
44
|
|
|
40
45
|
export function slugify(name) {
|
|
41
46
|
return String(name ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
@@ -58,7 +63,7 @@ export function slugify(name) {
|
|
|
58
63
|
* No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
|
|
59
64
|
* the binding names and keep it linked.
|
|
60
65
|
*/
|
|
61
|
-
export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false }) {
|
|
66
|
+
export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null }) {
|
|
62
67
|
const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
|
|
63
68
|
: { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
|
|
64
69
|
const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
|
|
@@ -73,6 +78,10 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
|
|
|
73
78
|
compatibility_flags: ['global_fetch_strictly_public'],
|
|
74
79
|
workers_dev: true,
|
|
75
80
|
preview_urls: true,
|
|
81
|
+
// The studio's own custom-domain and exact-host routes, as its owner wrote them: this file is written again by
|
|
82
|
+
// every deploy, and one written without them sends the studio's domain back to whatever else the zone routes
|
|
83
|
+
// (lib/routes.mjs). A studio without routes gets no key at all.
|
|
84
|
+
...(routes?.length ? { routes } : {}),
|
|
76
85
|
assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
|
|
77
86
|
durable_objects: { bindings: rooms },
|
|
78
87
|
migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
|
|
@@ -132,7 +141,23 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
132
141
|
how hard making it multiplayer will be; \`port import\` brings it into \`games/\`, \`port check\` runs the
|
|
133
142
|
owner tests (real touch, a late joiner, a killed host, two browsers finishing a round). The Homie
|
|
134
143
|
plugin's \`port\` skill does the whole job.
|
|
135
|
-
- \`npm run build\` — bundle every game into \`site/dist\`.
|
|
144
|
+
- \`npm run build\` — bundle every game into \`site/dist\`. It builds in a folder of its own and puts the result
|
|
145
|
+
in place only when all of it is there: a game that does not build fails the command (a non-zero exit) and
|
|
146
|
+
leaves \`site/dist\` exactly as it was, and nothing running from \`site/dist\` loses its files. Each game's line
|
|
147
|
+
says \`changed\`, \`unchanged\` or \`new\` (against the build before) and its build hash;
|
|
148
|
+
\`site/dist/_site/build.json\` keeps the same, and the live site says each game's hash in
|
|
149
|
+
\`/.well-known/homie-studio.json\` (\`games[].build.hash\`), so "is the live game the one I built?" is one
|
|
150
|
+
comparison. A game's bundle is named by its content (\`assets/main-<HASH>.js\`; its built \`index.html\` names
|
|
151
|
+
it, so write \`./assets/main.js\` in the game's own \`index.html\` as always), and \`await import('./later')\` in
|
|
152
|
+
a game becomes a file of its own that loads when asked for: a 3D game can show its first screen before the
|
|
153
|
+
rest of its code arrives.
|
|
154
|
+
- \`npx --no-install homie-studio build --types\` — the same, after checking the games' TypeScript (the build
|
|
155
|
+
itself only strips types; a type error ships otherwise). It uses the studio's own \`typescript\`
|
|
156
|
+
(\`npm install --save-dev typescript\` if this studio has none), reads a game's own \`tsconfig.json\` when it
|
|
157
|
+
has one, and a type error in a game's files stops the build.
|
|
158
|
+
- \`npx --no-install homie-studio preview <id>\` — one built game's files at an address on this computer
|
|
159
|
+
(\`http://127.0.0.1:8788/\`), nothing else: no Wrangler, no rooms, no database. For a capture, a screenshot or
|
|
160
|
+
a perf script; the game plays offline with its bots. Ctrl-C stops it.
|
|
136
161
|
- \`npm run dev\` — the whole site locally (pages, the netplay relay in a local
|
|
137
162
|
Durable Object, D1): open the printed address in two browsers and they share a room.
|
|
138
163
|
Stop it with \`npx --no-install homie-studio dev --stop\` (this studio's dev server only;
|
|
@@ -199,14 +224,18 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
199
224
|
page is not found.
|
|
200
225
|
- **A new studio goes live with its Home only:** its name and "First game coming soon", with what is on the way
|
|
201
226
|
(games, posts, music and videos), until its first game, song or video is published. A post shows there at once.
|
|
202
|
-
- **Every game gets a landing** at \`/<id>/\`: a full-bleed hero from the game's own
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
227
|
+
- **Every game gets a landing** at \`/<id>/\`: a full-bleed hero from the game's own files in
|
|
228
|
+
\`games/<id>/hero/\`: \`wide.jpg\` (16:9, 1280×720 or larger) and \`tall.jpg\` (9:16, 720×1280, what an upright
|
|
229
|
+
phone gets) for a still, and \`wide.mp4\` and \`tall.mp4\` in the same shapes for footage (a silent loop of 8 to
|
|
230
|
+
15 s, under 3 MB each); else a trailer in \`videos/\` with \`for.game\`; else its cover with slow motion. Any
|
|
231
|
+
size is shown, cropped to fill; one file may be 25 MiB at most. Then the pitch, a big Play button into a public room, phone / computer / TV with the join code,
|
|
232
|
+
live rooms, how to play and credits. Give it words in game.json's \`landing\` block
|
|
206
233
|
(\`pitch\`, \`about\`, \`controls\`, \`howToPlay\`, \`credits\`) and art with the plugin's \`art\` and \`video\` skills.
|
|
207
234
|
- **The look** is \`site/theme.json\` (colours, fonts, corner radius, a logo). Anything in \`site/\` wins: a whole
|
|
208
235
|
page in \`site/pages/\`, a piece of every page in \`site/partials/\`, files in \`site/public/\`, extra CSS in
|
|
209
|
-
\`site/theme.css\` (\`site/README.md\`).
|
|
236
|
+
\`site/theme.css\` (\`site/README.md\`). **The order of the games** everywhere (Home, the Games page, the cards,
|
|
237
|
+
the directory's manifest) is studio.json \`"site": { "order": ["<id>", "<id>"] }\`: those first, as named, the
|
|
238
|
+
rest by id; \`"site": { "featured": "<id>" }\` picks Home's hero. A game whose picture is white or cream gets a light landing with
|
|
210
239
|
game.json \`"landing": { "scheme": "light" }\` (a dark tint would turn it grey).
|
|
211
240
|
- **Cards and the directory** show each game's landing still (\`hero/wide.jpg\`), else its cover. A song without
|
|
212
241
|
a cover of its own shows the music manifest's \`cover\`, else its game's still.
|
|
@@ -243,14 +272,47 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
243
272
|
Auto cuts to it (\`NETPLAY.md\` section 16). A game that never reads \`viewSeat\` is watched as its overview. A
|
|
244
273
|
game with hidden hands or roles says game.json \`"watch": "overview"\` (the whole room only) or \`false\` (no
|
|
245
274
|
watch door); a private or invite-only game is watched only by those it lets in.
|
|
275
|
+
- **A touch game guards its own page.** The play page refuses text selection, the long-press callout and page
|
|
276
|
+
gestures around the game's frame, but a long press INSIDE the game (a HUD label, a button's text, the canvas) is
|
|
277
|
+
the game's own: on a phone it raises copy/paste and the thumb's touch is lost. Call
|
|
278
|
+
\`guardGestures({ touch: 'canvas, [data-action]' })\` from \`@homie-rocks/studio/netplay\` once, early: no
|
|
279
|
+
selection, no callout, no pan or pinch on the gameplay surfaces; text fields, links and ordinary buttons keep the
|
|
280
|
+
browser's own behaviour (\`NETPLAY.md\` section 24). Try it with a real thumb on a real phone: emulated touch
|
|
281
|
+
does not raise the callout.
|
|
282
|
+
- **The frame has no storage of its own.** The game runs in a sandboxed frame without \`allow-same-origin\` (a
|
|
283
|
+
stranger's game must never read this site's storage or the owner's session), so \`localStorage\` throws inside it.
|
|
284
|
+
A setting or a personal best goes in \`net.prefs\` (\`net.prefs.string('quality', 'high')\`, \`net.prefs.number('volume', 0.8, { min: 0, max: 1 })\` after \`await net.prefs.ready\`: the typed readers give your fallback for a missing key, never a null that turns into 0,
|
|
285
|
+
\`net.prefs.set('quality', 'low')\`: the play page keeps 16 KB a game); progress that must last goes in saves.
|
|
286
|
+
The play page's \`?debug\` and \`?q=…\` reach the game as \`net.params\`, with any names game.json
|
|
287
|
+
\`"netplay": { "params": ["seed"] }\` declares (\`NETPLAY.md\` section 24).
|
|
288
|
+
- **game.json \`"netplay"\`** also takes \`"version"\` (the game's revision: bump it when a change makes an
|
|
289
|
+
already-open tab unable to play with a new one; rooms then run one build at a time and the old tab is told to
|
|
290
|
+
reload) and \`"stallMs"\` (1500 to 10000: how long a host may send no snapshot before the room is handed on, for
|
|
291
|
+
a heavy 3D game). \`NETPLAY.md\` sections 22 and 23.
|
|
246
292
|
- Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
|
|
247
293
|
- A game's id is its URL (\`/<id>/\`); keep it once published.
|
|
248
|
-
- **
|
|
249
|
-
game,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
game
|
|
253
|
-
|
|
294
|
+
- **A game's own licence.** game.json \`"license": "MIT"\` (any SPDX identifier) is said on the game's page; a game
|
|
295
|
+
that names none says nothing. It is a statement about the game, not an offer of its source: no game is handed
|
|
296
|
+
over whole. A game.json that still has \`"remix"\`, \`"share"\`, \`landing.make\` or one of the old licence words
|
|
297
|
+
builds as before with one note (remix was retired; delete them). A game that has \`"remixOf"\` was made from another
|
|
298
|
+
studio's game: keep it, it is the credit its landing and credits show ("Based on <game> by <studio>").
|
|
299
|
+
- **A 3D game's models.** \`createModels()\` warns in development about a model over 1,500 triangles or 300 KB,
|
|
300
|
+
which is a small prop's budget. A game whose models are bigger on purpose says so once in its game.json:
|
|
301
|
+
\`"assets": { "budgets": { "triangles": 8000, "bytes": 1500000 } }\` (per model; \`texturePx\` and \`materials\`
|
|
302
|
+
too). A game copied from a 3D starter has \`"assets": "library"\` there, which only mattered when it was copied:
|
|
303
|
+
replace it with the object.
|
|
304
|
+
- **Parts: how games build on each other.** A part is a piece of a game its studio chose to share (a creature, a
|
|
305
|
+
level generator, a camera, a bot brain, an audio pack), never the whole game. **Before writing a system from
|
|
306
|
+
scratch, look for one**: the \`@homie-rocks/*\` packages (npm) have the general mechanism (camera, input, audio,
|
|
307
|
+
effects), and \`parts_find\` searches the pieces other studios shared; packages are not parts. \`part_add\` with
|
|
308
|
+
\`"<studio site>/<part id>"\` fetches one exact version, checks every file, copies it into \`parts/_vendor/\` where
|
|
309
|
+
the studio owns and tunes it (its \`tuning.json\` survives a newer version), credits it in the game, and lets npm
|
|
310
|
+
install the packages it builds on; the game imports it in one line, \`import … from '@parts/<studio site>/<part
|
|
311
|
+
id>'\`. Write in the game's CODEX (Built from) what came from where. A reusable piece of this studio's own game
|
|
312
|
+
becomes a part with \`part_new\` (it is lifted out and the game keeps working). **A part is private until the
|
|
313
|
+
person asks to share it** (\`part_share\`, with an SPDX licence they picked and who to credit); it is live after
|
|
314
|
+
the next deploy. Without the chat tools, the same jobs are \`npx --no-install homie-studio parts
|
|
315
|
+
find|add|new|share\`. The design is \`node_modules/@homie-rocks/studio/parts/PARTS.md\`.
|
|
254
316
|
- **Progress that lasts** (a character, unlocks, a collection, days of play) goes in **saves**, never in the room:
|
|
255
317
|
a room forgets everything 60 s after its last player leaves. game.json \`"saves": true\` and
|
|
256
318
|
\`createSaves\` from \`@homie-rocks/studio/saves\` (\`node_modules/@homie-rocks/studio/saves/SAVES.md\`): per
|
|
@@ -301,8 +363,7 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
301
363
|
lean, look-at, crowds; every number a Game Lab tunable); \`game new <id> --from hero-rush-3d\` starts one. The
|
|
302
364
|
plugin's \`animate\` skill has the rest.
|
|
303
365
|
- **Licences**: a public game ships only assets whose licence allows it (CC0, CC BY with credit, the studio's own,
|
|
304
|
-
generated); \`publish\` refuses an asset with no licence record.
|
|
305
|
-
checked by SHA-256, and a grey placeholder for the rest.
|
|
366
|
+
generated); \`publish\` refuses an asset with no licence record.
|
|
306
367
|
|
|
307
368
|
## Running live games (the back office)
|
|
308
369
|
|
|
@@ -310,12 +371,12 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
310
371
|
the owner a one-time sign-in link): every live room of every game, who is in it (handles; accounts once players
|
|
311
372
|
sign in), bots, the round and uptime, refreshing by itself; Kick (that player cannot come back to that room for
|
|
312
373
|
the minutes chosen), Mute (their chat and emotes reach nobody), Announce (one line every player sees), Close a
|
|
313
|
-
room; and per game: launch state,
|
|
374
|
+
room; and per game: launch state, players per room and invites.
|
|
314
375
|
- **Launch states:** \`private\` (only the owner, signed in; \`office link --to /<id>/play\` signs the owner's phone
|
|
315
376
|
in), \`invite\` (an invite-only beta: \`office invite <id>\` makes invite links and codes, each for one browser or
|
|
316
377
|
as many as \`--uses\` says), \`public\` (the default; listed). A game that is not public is in no list and not in
|
|
317
378
|
the directory manifest, so the directory drops it the next time it reads the studio. A new game stays private
|
|
318
|
-
from its first deploy with \`"launch": "private"\` in its game.json.
|
|
379
|
+
from its first deploy with \`"launch": "private"\` in its game.json.
|
|
319
380
|
- The owner is recognised in their own games: signed in, their play page has a small Owner button (tap a player:
|
|
320
381
|
Mute, Kick; Announce). Nobody else's page has it.
|
|
321
382
|
- **From the AI:** \`npx --no-install homie-studio office\` lists who is playing now; \`office announce "<text>"\` and
|
|
@@ -428,8 +489,8 @@ chat's card follows the work from then on) and, for a studio still being set up,
|
|
|
428
489
|
\`"games/*/game.json"\`) and \`"budget"\` (\`{ "usd": <n>, "credits": <n> }\`: the most the studio's media jobs spend
|
|
429
490
|
in all). In Claude Code, the Homie plugin's mod holds an edit to a protected file, a deploy, and a paid call past
|
|
430
491
|
the budget until the person says Proceed, and takes keys out of command output. In Codex the plugin's hooks hold
|
|
431
|
-
the same calls until the person's own \`proceed <code>\`, once the person has trusted them
|
|
432
|
-
|
|
492
|
+
the same calls until the person's own \`proceed <code>\`, once the person has trusted them, and in Grok Build once
|
|
493
|
+
the plugin is trusted. \`npx --no-install homie-studio setup status --client codex\` (or \`grok\`) says whether Homie's
|
|
433
494
|
holds are on, and while they are off, ask before each of those yourself. When a call is refused, say what you
|
|
434
495
|
meant to do and why; do not retry it unless the person asks.
|
|
435
496
|
|
|
@@ -485,7 +546,7 @@ That line is all the session is given. The brief (what the person asked for, in
|
|
|
485
546
|
|
|
486
547
|
1. \`npm install\` (once per session).
|
|
487
548
|
2. \`npx --no-install homie-studio handoff hb_…\` prints the brief and the steps for this kind of build (a new
|
|
488
|
-
studio's first session, a new game, a port
|
|
549
|
+
studio's first session, a new game, a port or a change). It takes the build once, so the chat's card
|
|
489
550
|
follows the work, and for a studio still being set up it checks in from this repository.
|
|
490
551
|
3. If it says this session's network does not reach homie.rocks and the Homie connector's tools are in this session,
|
|
491
552
|
call \`build_progress\` with \`{ "build": "hb_…" }\`: its answer carries the same brief. Otherwise tell the person
|
|
@@ -554,7 +615,7 @@ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site
|
|
|
554
615
|
| \`theme.json\` | The look: \`bg\`, \`fg\`, \`accent\`, \`glow\` colours, \`display\` and \`text\` fonts (\`fonts\` loads a file from \`public/\`), \`radius\`, \`mark\` (a logo). Or \`palette\`: neon, dock, gold, acid, ember, orchid, tide, candy. |
|
|
555
616
|
| \`theme.css\` | Extra CSS on every page (restyle anything, the "Made with Homie" footer included). |
|
|
556
617
|
| \`partials/<name>.html\` | A piece of every page: \`head\`, \`header\`, \`footer\`, \`home\` (a band on Home), \`game\` (a band on every game's landing), \`game-<id>\` (on one game's), \`post\`. |
|
|
557
|
-
| \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces a game's landing) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`, and \`<!-- homie:schema -->\` in its \`<head>\` (the page's structured data for search engines). |
|
|
618
|
+
| \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces a game's landing) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`, and \`<!-- homie:schema -->\` in its \`<head>\` (the page's structured data for search engines). A Home of your own (\`pages/index.html\`) takes the generated hero with \`<!-- homie:home-hero -->\` (the featured game) or \`<!-- homie:home-hero <id> -->\` (that game); a game's own hero files are served at \`/games/<id>/_landing/<file>\` (\`wide.jpg\`, \`tall.jpg\`, \`wide.mp4\`, \`tall.mp4\`) if a page of yours needs one by address (\`/api/games\` says each exactly, in \`landing.hero\`). |
|
|
558
619
|
| \`public/\` | Files served as they are, at the same path (\`public/fonts/x.woff2\` is \`/fonts/x.woff2\`). A \`robots.txt\`, \`sitemap.xml\` or \`llms.txt\` here replaces the one the site makes. |
|
|
559
620
|
|
|
560
621
|
\`src/worker.mjs\` and \`migrations/\` are the Worker (its config is \`wrangler.jsonc\`, at the studio's root); \`dist/\` is the build (not committed).
|
|
@@ -620,7 +681,7 @@ export function studioFiles({ name, slug, homie, template = false }) {
|
|
|
620
681
|
private: true,
|
|
621
682
|
type: 'module',
|
|
622
683
|
scripts: { dev: 'homie-studio dev', build: 'homie-studio build', deploy: 'homie-studio deploy', check: 'homie-studio check', studio: 'homie-studio' },
|
|
623
|
-
devDependencies: { '@homie-rocks/studio': packageSpec(homie), wrangler: WRANGLER_VERSION },
|
|
684
|
+
devDependencies: { '@homie-rocks/studio': packageSpec(homie), typescript: TYPESCRIPT_VERSION, wrangler: WRANGLER_VERSION },
|
|
624
685
|
// What Cloudflare's "Deploy to Cloudflare" form says about each setting (package.json `cloudflare.bindings`).
|
|
625
686
|
cloudflare: {
|
|
626
687
|
label: 'Homie studio',
|