@homie-rocks/studio 0.5.0 → 0.7.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 -2
- package/bin/homie-studio.mjs +75 -11
- package/lib/build.mjs +72 -9
- package/lib/cloudflare.mjs +28 -11
- package/lib/directory.mjs +2 -2
- package/lib/look.mjs +101 -0
- package/lib/markdown.mjs +121 -0
- package/lib/media.mjs +2 -2
- package/lib/scaffold.mjs +115 -8
- package/lib/site.mjs +437 -0
- package/lib/stats.mjs +125 -0
- package/lib/studio.mjs +43 -1
- package/netplay/NETPLAY.md +55 -12
- package/netplay/netplay.ts +19 -2
- package/package.json +3 -2
- package/site/SITE.md +128 -0
- package/worker/index.mjs +416 -136
- package/worker/pages.mjs +151 -208
- package/worker/room.mjs +37 -3
- package/worker/seats.mjs +20 -0
- package/worker/site.mjs +1052 -0
- package/worker/stats-page.mjs +144 -0
- package/worker/stats.mjs +314 -0
- package/worker/version.mjs +2 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Most people never run this by hand: the Homie plugin for Claude Code and Codex d
|
|
|
10
10
|
and the person approves Cloudflare once in their browser.
|
|
11
11
|
|
|
12
12
|
```sh
|
|
13
|
-
npx -y --package=https://homie.rocks/npm/homie-studio-0.
|
|
13
|
+
npx -y --package=https://homie.rocks/npm/homie-studio-0.7.0.tgz homie-studio new ./night-owls --name "Night Owls"
|
|
14
14
|
cd night-owls && npm install
|
|
15
15
|
npx homie-studio game new crown-thief --from gem-rush --name "Crown Thief"
|
|
16
16
|
npx homie-studio dev # the whole site locally
|
|
@@ -18,8 +18,58 @@ npx homie-studio check crown-thief --url http://127.0.0.1:8787
|
|
|
18
18
|
npx homie-studio deploy --plan # what deploy will create, and what it costs; changes nothing
|
|
19
19
|
npx homie-studio deploy # the studio's own Cloudflare
|
|
20
20
|
npx homie-studio publish # the homie.rocks directory
|
|
21
|
+
npx homie-studio stats # the studio's own numbers, for its owner
|
|
21
22
|
```
|
|
22
23
|
|
|
24
|
+
## The site: the hub's shape, and a landing for every game
|
|
25
|
+
|
|
26
|
+
Every studio site has the same sections as homie.rocks, in the studio's own look (`site/theme.json`): Home (the
|
|
27
|
+
featured game, live rooms, the latest posts), Games, Music, Videos, Rooms (every public room playing now,
|
|
28
|
+
joinable) and Posts (`posts/*.md`, with Atom and JSON feeds). A section with nothing in it has no tab, and its
|
|
29
|
+
page answers 404. Every game gets a landing at `/<game>/`: a full-bleed hero from its own footage or art, the
|
|
30
|
+
pitch, a big Play button into a public room, how to play on a phone, a computer and a TV (with the join code),
|
|
31
|
+
live rooms, credits (the original and its licence for a port), and "Make a game like this". Every page ends with
|
|
32
|
+
"Made with Homie". Anything the studio puts in `site/` wins: a whole page, a partial, its tokens, its CSS, its
|
|
33
|
+
files. `site/SITE.md` says all of it.
|
|
34
|
+
|
|
35
|
+
The play page writes its room into the address, and a small room button at the edge shares it: Invite, Big
|
|
36
|
+
screen and the room code. Every HTML answer is `no-transform`, and the site's pages are never framed by another
|
|
37
|
+
site.
|
|
38
|
+
|
|
39
|
+
## Rooms of up to 32
|
|
40
|
+
|
|
41
|
+
Every room of a game has the seats its netplay manifest names: game.json's `netplay`
|
|
42
|
+
block (`maxPlayers`), or a `netplay.json` beside game.json or in the game's build
|
|
43
|
+
(`maxPlayers` or `players.max`), else game.json's `players.max`, else 8; at most 32.
|
|
44
|
+
Bots, a late joiner taking a bot's body and a new host after the old one leaves work
|
|
45
|
+
the same at 32 as at 4. One address may hold every seat plus four sockets, so a party
|
|
46
|
+
on one Wi-Fi (or strangers behind one carrier's address) fills a room with a TV beside it.
|
|
47
|
+
|
|
48
|
+
## The studio's own stats
|
|
49
|
+
|
|
50
|
+
The site counts, and never tracks: pages opened, Play presses, rooms opened, the most
|
|
51
|
+
people playing at once, rounds finished, songs played, videos watched, and which site
|
|
52
|
+
sent each visitor (a host name: homie.rocks, another studio, search, the web, or a
|
|
53
|
+
`?via=` tag). They are daily counters in the studio's own D1, on the free plan (a write
|
|
54
|
+
per visit and per round, never per frame), with no cookie on a visitor and nothing sent
|
|
55
|
+
to anyone. Prefetches, crawlers and house QA are not counted. Only the owner reads them:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
npx homie-studio stats [--range 7d] [--game crown-thief] # prints them
|
|
59
|
+
npx homie-studio stats link # a one-time link: /_studio/stats in the owner's browser
|
|
60
|
+
npx homie-studio stats key # a 1-hour read key for the Homie MCP tool studio_stats
|
|
61
|
+
npx homie-studio stats share on # tell the directory "played this week" (two numbers)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
|
|
65
|
+
studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
|
|
66
|
+
|
|
67
|
+
## The site's address
|
|
68
|
+
|
|
69
|
+
`deploy` keeps the `workers.dev` address in `.studio/local.json`, which git ignores: it
|
|
70
|
+
names the Cloudflare account, often after its owner. A custom domain goes in studio.json
|
|
71
|
+
as `cloudflare.domain` and is what the directory claim, `publish`, `check` and `stats` use.
|
|
72
|
+
|
|
23
73
|
## No payment method needed
|
|
24
74
|
|
|
25
75
|
`deploy` creates only what Cloudflare's free Workers plan gives a new account with no
|
|
@@ -61,7 +111,9 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
61
111
|
| `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
|
|
62
112
|
| `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
|
|
63
113
|
| `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`. |
|
|
64
|
-
| `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from storage at `/media/<key>`). |
|
|
114
|
+
| `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from storage at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
|
|
115
|
+
| `worker/site.mjs`, `lib/site.mjs`, `lib/markdown.mjs`, `site/SITE.md` | The site: its sections, each game's landing, posts and their feeds, the look (theme tokens) and what the studio's `site/` folder overrides; the safe markdown posts are written in. `worker/pages.mjs`: the play page. |
|
|
116
|
+
| `worker/stats.mjs`, `worker/stats-page.mjs`, `lib/stats.mjs` | The studio's own stats: what is counted and how, the owner-only `/api/stats` and `/_studio/stats`, and `homie-studio stats`. |
|
|
65
117
|
| `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's storage, or a link). `media list` shows it; `media put` uploads a file to storage and records its key. |
|
|
66
118
|
| `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
|
|
67
119
|
| `starters/gem-rush/` | The reference multiplayer starter. |
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* homie-studio build [<id>]
|
|
13
13
|
* homie-studio dev [--port 8787] (--stop: stop exactly this studio's dev server, nothing else)
|
|
14
14
|
* homie-studio check <id> [--url <site>] [--shots <dir>]
|
|
15
|
+
* homie-studio look [<path>...] [--url <site>] [--shots <dir>] [--only computer,phone,sideways]
|
|
16
|
+
* (the site's pages on a computer and a phone, as pictures, with what is wrong)
|
|
15
17
|
* homie-studio port plan <game folder>
|
|
16
18
|
* homie-studio port import <game folder> --id <id> [--name "<Name>"] [--mode static|bundle|command]
|
|
17
19
|
* homie-studio port check <id> [--url <site>] [--only owner-desk,owner-phone,owner-iphone,round,life,tv] [--shots <dir>]
|
|
@@ -21,6 +23,11 @@
|
|
|
21
23
|
* homie-studio media put <file> [--as <key>]
|
|
22
24
|
* homie-studio media list
|
|
23
25
|
* homie-studio status
|
|
26
|
+
* homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>] [--url <site>]
|
|
27
|
+
* homie-studio stats key [--hours 1] (a read key for the Homie MCP tool studio_stats)
|
|
28
|
+
* homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
|
|
29
|
+
* homie-studio stats revoke (every stats key and page session ends)
|
|
30
|
+
* homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
|
|
24
31
|
*
|
|
25
32
|
* Every command prints a few lines for a person; --json prints the result.
|
|
26
33
|
*/
|
|
@@ -31,12 +38,14 @@ import { build } from '../lib/build.mjs';
|
|
|
31
38
|
import { check } from '../lib/check.mjs';
|
|
32
39
|
import { deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
33
40
|
import { publish } from '../lib/directory.mjs';
|
|
41
|
+
import { look } from '../lib/look.mjs';
|
|
34
42
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
35
43
|
import { portCheck } from '../lib/port-check.mjs';
|
|
36
44
|
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
37
|
-
import { newStudio } from '../lib/scaffold.mjs';
|
|
38
|
-
import { listGames, newGame, readStudio, remixGame, requireStudio, starters } from '../lib/studio.mjs';
|
|
45
|
+
import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
|
|
46
|
+
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
|
|
39
47
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
48
|
+
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
40
49
|
|
|
41
50
|
const argv = process.argv.slice(2);
|
|
42
51
|
const flags = new Map();
|
|
@@ -56,7 +65,7 @@ const log = asJson ? () => {} : (line) => process.stderr.write(`${line}\n`);
|
|
|
56
65
|
|
|
57
66
|
function print(result) {
|
|
58
67
|
if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; }
|
|
59
|
-
if (result.ok === false && result.command !== 'port check') { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
68
|
+
if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
60
69
|
if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
61
70
|
const lines = [];
|
|
62
71
|
switch (result.command) {
|
|
@@ -75,7 +84,12 @@ function print(result) {
|
|
|
75
84
|
case 'build':
|
|
76
85
|
lines.push(`Built ${result.games.map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'no games'} into ${relative(process.cwd(), result.dist) || result.dist}`);
|
|
77
86
|
if (result.songs?.length || result.videos?.length) lines.push(`Media pages: ${[...result.songs.map((x) => `/music/${x}/`), ...result.videos.map((x) => `/videos/${x}/`)].join(', ')}`);
|
|
87
|
+
for (const l of result.landings ?? []) lines.push(`Landing /${l.id}/: hero from ${l.hero === 'footage' ? 'its footage' : l.hero === 'art' ? 'its art (moving)' : 'the studio\'s colours (add a cover or hero/ footage)'}${l.credits ? ', credits' : ''}${l.source ? ', "Make a game like this" with its source' : ''}`);
|
|
88
|
+
if (result.posts?.length) lines.push(`Posts: ${result.posts.map((x) => `/posts/${x}/`).join(', ')} (feeds: /posts/feed.xml, /posts/feed.json)`);
|
|
89
|
+
if (result.pages?.length || result.partials?.length) lines.push(`The studio's own: ${[...(result.pages ?? []).map((x) => `page ${x}`), ...(result.partials ?? []).map((x) => `partial ${x}`)].join(', ')}`);
|
|
78
90
|
for (const m of result.mediaSkipped ?? []) lines.push(` left out: ${m.kind}/${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
|
|
91
|
+
for (const m of result.postsSkipped ?? []) lines.push(` left out: posts/${m.post}: ${m.why}`);
|
|
92
|
+
for (const m of result.siteSkipped ?? []) lines.push(` left out: ${m.what}: ${m.why}`);
|
|
79
93
|
break;
|
|
80
94
|
case 'media list':
|
|
81
95
|
for (const kind of ['music', 'videos']) {
|
|
@@ -85,7 +99,9 @@ function print(result) {
|
|
|
85
99
|
}
|
|
86
100
|
break;
|
|
87
101
|
case 'deploy':
|
|
88
|
-
lines.push(`Live: ${result.url}`, ...result.
|
|
102
|
+
lines.push(`Live: ${result.url}`, ...(result.workersDev && result.workersDev !== result.url ? [` also at ${result.workersDev}`] : []),
|
|
103
|
+
...(result.local ? [` (the workers.dev address names your Cloudflare account, so it is kept in ${result.local} on this computer, never in studio.json)`] : []),
|
|
104
|
+
...result.games.map((g) => ` ${g.id}: ${g.play}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}, Durable Objects Table + Lobby${result.r2 ? `, R2 ${result.r2}` : ' (no storage: none needed; `homie-studio storage add` adds it for large media)'}. All on the free Workers plan${result.r2 ? ' plus R2' : ''}.`,
|
|
89
105
|
result.claim ? 'Directory claim stored: list the games with the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish' : 'No directory claim yet (the directory did not answer); publish will try again.');
|
|
90
106
|
break;
|
|
91
107
|
case 'deploy plan':
|
|
@@ -116,6 +132,32 @@ function print(result) {
|
|
|
116
132
|
lines.push(`${result.ok ? 'PASS' : 'NOT YET'}: ${result.game} at ${result.url} (${Math.round(result.totalMs / 1000)} s)`,
|
|
117
133
|
...result.passed.map((p) => ` ok ${p}`), ...result.failed.map((f) => ` FAIL ${f}`), ...result.skipped.map((f) => ` skip ${f}`), '', `Receipt and screenshots: ${result.out}`);
|
|
118
134
|
break;
|
|
135
|
+
case 'stats': {
|
|
136
|
+
const t = result.totals;
|
|
137
|
+
const n = (x) => Number(x || 0).toLocaleString('en-US');
|
|
138
|
+
lines.push(`${result.studio ?? 'Studio'}: ${result.range.from} to ${result.range.to} (UTC)${result.only ? `, ${result.only.kind} ${result.only.id}` : ''}`,
|
|
139
|
+
` ${n(t.visits)} visits · ${n(t.plays)} Play presses · ${n(t.rooms)} rooms opened · ${n(t.rounds)} rounds finished (${n(t.roundsWithPeople)} with people, ${n(t.peopleInRounds)} people in them)`,
|
|
140
|
+
` most playing at once: ${n(t.peakPlayers)} (${n(t.peakInOneRoom)} in one room) · playing now: ${n(t.playingNow)} · songs played: ${n(t.songPlays)} · videos watched: ${n(t.videoViews)}`,
|
|
141
|
+
` came from homie.rocks: ${n(result.crossings.fromHub)} · other studios: ${n(result.crossings.fromStudios)} · search: ${n(result.crossings.fromSearch)} · the web: ${n(result.crossings.fromWeb)} · ?via= links: ${n(result.crossings.fromLinks)}`);
|
|
142
|
+
for (const g of result.games) lines.push(` game ${g.id}: ${n(g.visits)} visits, ${n(g.plays)} plays, ${n(g.rooms)} rooms, ${n(g.rounds)} rounds, peak ${n(g.peakPlayers)}, now ${n(g.playingNow)}`);
|
|
143
|
+
for (const e of result.songs) lines.push(` song ${e.slug}: ${n(e.visits)} page visits, played ${n(e.plays)}`);
|
|
144
|
+
for (const e of result.videos) lines.push(` video ${e.slug}: ${n(e.visits)} page visits, watched ${n(e.views)}`);
|
|
145
|
+
for (const r of result.referrers.slice(0, 10)) lines.push(` from ${r.from} (${r.kind}): ${n(r.visits)} visits, ${n(r.plays)} plays`);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case 'stats key':
|
|
149
|
+
lines.push(`Read key (until ${result.expiresAt}): ${result.key}`, result.use);
|
|
150
|
+
break;
|
|
151
|
+
case 'stats link':
|
|
152
|
+
lines.push(`One-time link (until ${result.expiresAt}): ${result.link}`, result.use);
|
|
153
|
+
break;
|
|
154
|
+
case 'stats revoke':
|
|
155
|
+
case 'stats share':
|
|
156
|
+
lines.push(result.message);
|
|
157
|
+
break;
|
|
158
|
+
case 'look':
|
|
159
|
+
lines.push(`${result.ok ? 'Looks right' : 'Look again'}: ${result.rows.length} views of ${result.url}`, ...result.rows.map((r) => ` ${r.ok ? 'ok ' : 'FIX '} ${r.path} on ${({ computer: 'a computer', phone: 'a phone', sideways: 'a phone turned sideways' })[r.device] ?? r.device}${r.problems.length ? `: ${r.problems.join('; ')}` : ''}`), '', `Pictures (open them and look): ${result.shots}`);
|
|
160
|
+
break;
|
|
119
161
|
case 'check':
|
|
120
162
|
lines.push(`PASS: two fresh browsers in room ${result.room} finished round ${result.round.n} (${result.round.humans} humans, ${result.round.bots} bots) in ${Math.round(result.totalMs / 1000)} s.`,
|
|
121
163
|
...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
|
|
@@ -142,7 +184,7 @@ async function main() {
|
|
|
142
184
|
if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
|
|
143
185
|
if (cmd === 'port' && sub === 'check') {
|
|
144
186
|
const game = positional[2] ?? listGames(root)[0]?.id;
|
|
145
|
-
const url = flags.get('url') ??
|
|
187
|
+
const url = flags.get('url') ?? siteUrl(root);
|
|
146
188
|
if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
|
|
147
189
|
return portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
|
|
148
190
|
}
|
|
@@ -152,16 +194,29 @@ async function main() {
|
|
|
152
194
|
if (cmd === 'build') return build(root, { only: positional[1] ?? null, log });
|
|
153
195
|
if (cmd === 'status') {
|
|
154
196
|
const studio = readStudio(root);
|
|
155
|
-
return { ok: true, command: 'status', root, studio, games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
|
|
197
|
+
return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
|
|
156
198
|
}
|
|
157
199
|
if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
|
|
158
200
|
if (cmd === 'dev') return dev(root);
|
|
159
201
|
if (cmd === 'check') {
|
|
160
202
|
const game = positional[1] ?? listGames(root)[0]?.id;
|
|
161
|
-
const url = flags.get('url') ??
|
|
203
|
+
const url = flags.get('url') ?? siteUrl(root);
|
|
162
204
|
if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
|
|
163
205
|
return check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
|
|
164
206
|
}
|
|
207
|
+
if (cmd === 'look') {
|
|
208
|
+
const url = flags.get('url') ?? siteUrl(root);
|
|
209
|
+
if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
|
|
210
|
+
let paths = positional.slice(1).map((p) => (p.startsWith('/') ? p : `/${p}`));
|
|
211
|
+
if (!paths.length) {
|
|
212
|
+
let cat = null;
|
|
213
|
+
try { cat = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')); } catch { /* not built */ }
|
|
214
|
+
const games = cat?.games?.map((g) => g.id) ?? listGames(root).map((g) => g.id);
|
|
215
|
+
paths = ['/', ...games.map((id) => `/${id}/`), ...(games.length ? ['/games/', '/rooms/'] : []), ...(cat?.posts?.length ? ['/posts/', `/posts/${cat.posts[0].slug}/`] : [])];
|
|
216
|
+
}
|
|
217
|
+
const only = flags.get('only') ? String(flags.get('only')).split(',').filter((d) => ['computer', 'phone', 'sideways'].includes(d)) : undefined;
|
|
218
|
+
return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
|
|
219
|
+
}
|
|
165
220
|
if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
|
|
166
221
|
if (cmd === 'deploy') return deploy(root, { log, homie: flags.get('homie') });
|
|
167
222
|
if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
|
|
@@ -171,12 +226,17 @@ async function main() {
|
|
|
171
226
|
return { ok: true, command: 'storage', storage: has ? { kind: 'r2', bucket: cf.r2 } : null, why: has ? undefined : 'no storage yet: the studio runs without it; `homie-studio storage add` adds an R2 bucket for large media (Cloudflare asks for a payment method before R2 works)' };
|
|
172
227
|
}
|
|
173
228
|
if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site') });
|
|
229
|
+
if (cmd === 'stats' && sub === 'key') return statsKey(root, { url: flags.get('url'), hours: flags.get('hours') });
|
|
230
|
+
if (cmd === 'stats' && sub === 'link') return statsLink(root, { url: flags.get('url') });
|
|
231
|
+
if (cmd === 'stats' && sub === 'revoke') return statsRevoke(root, { url: flags.get('url') });
|
|
232
|
+
if (cmd === 'stats' && sub === 'share') return statsShare(root, positional[2]);
|
|
233
|
+
if (cmd === 'stats' && !sub) return statsShow(root, { url: flags.get('url'), range: flags.get('range'), game: flags.get('game'), song: flags.get('song'), video: flags.get('video') });
|
|
174
234
|
if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
|
|
175
235
|
if (cmd === 'media' && sub === 'list') {
|
|
176
236
|
const studio = readStudio(root);
|
|
177
237
|
const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
|
|
178
238
|
const view = (kind) => { const r = resolveMedia(root, kind, { r2 }); return { pages: r.entries.map((e) => ({ slug: e.slug, title: e.title, kind: e.kind, files: e.files.map(({ abs, rel, ...f }) => f) })), skipped: r.skipped }; };
|
|
179
|
-
return { ok: true, command: 'media list', r2, site: studio
|
|
239
|
+
return { ok: true, command: 'media list', r2, site: siteUrl(root, studio), music: view('music'), videos: view('videos') };
|
|
180
240
|
}
|
|
181
241
|
return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
|
|
182
242
|
}
|
|
@@ -222,6 +282,8 @@ async function dev(root) {
|
|
|
222
282
|
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
223
283
|
const studio = readStudio(root);
|
|
224
284
|
const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' };
|
|
285
|
+
const added = ensureStatsMigration(root);
|
|
286
|
+
if (added) log(`added ${added} (the studio's own stats)`);
|
|
225
287
|
await new Promise((done) => {
|
|
226
288
|
const m = spawn(bin, ['d1', 'migrations', 'apply', studio.cloudflare.d1, '--local'], { cwd: join(root, 'site'), env, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
227
289
|
m.on('close', done);
|
|
@@ -253,8 +315,10 @@ function mediaPut(root, file, as) {
|
|
|
253
315
|
const p = spawn(bin, ['r2', 'object', 'put', `${r2}/${key}`, '--file', resolve(file), '--content-type', typeOf(file), '--remote'], { cwd: join(root, 'site'), env: { ...process.env, CI: '1' }, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
254
316
|
p.on('close', (code) => {
|
|
255
317
|
if (code !== 0) return done({ ok: false, command: 'media put', why: 'wrangler r2 object put failed' });
|
|
256
|
-
|
|
257
|
-
|
|
318
|
+
// The manifest is committed: it names the file by its path on the site, never the workers.dev address.
|
|
319
|
+
const rec = recordUpload(root, rel, key, statSync(file).size);
|
|
320
|
+
const site = siteUrl(root, studio);
|
|
321
|
+
done({ ok: true, command: 'media put', key, manifest: rec.manifest, entry: rec.entry, url: site ? `${site}/media/${key}` : null });
|
|
258
322
|
});
|
|
259
323
|
});
|
|
260
324
|
}
|
|
@@ -264,7 +328,7 @@ try {
|
|
|
264
328
|
if (result && result.command !== 'help') print(result);
|
|
265
329
|
if (result?.ok === false) process.exitCode = 1;
|
|
266
330
|
// Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
|
|
267
|
-
if (result?.command === 'check' || result?.command === 'port check') process.exit(process.exitCode ?? 0);
|
|
331
|
+
if (result?.command === 'check' || result?.command === 'port check' || result?.command === 'look') process.exit(process.exitCode ?? 0);
|
|
268
332
|
} catch (error) {
|
|
269
333
|
print({ ok: false, why: error instanceof Error ? error.message : String(error) });
|
|
270
334
|
process.exitCode = 1;
|
package/lib/build.mjs
CHANGED
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
* site/dist/games/<id>/index.html the game's page (the Worker adds HOMIE_NET)
|
|
6
6
|
* site/dist/games/<id>/assets/main.js its bundle (esbuild; @homie-rocks/studio/netplay inlined)
|
|
7
7
|
* site/dist/games/<id>/... everything in games/<id>/public/
|
|
8
|
-
* site/dist/games.json { studio, games[], songs[], videos[] } from studio.json,
|
|
9
|
-
* files
|
|
8
|
+
* site/dist/games.json { studio, games[], songs[], videos[], posts[], site } from studio.json,
|
|
9
|
+
* game.json files, the music/ and videos/ manifests (media/MEDIA.md),
|
|
10
|
+
* posts/*.md and the studio's site/ folder (site/SITE.md)
|
|
11
|
+
* site/dist/games/<id>/_landing/... what the game's landing shows (hero footage, its cover, licence texts)
|
|
12
|
+
* site/dist/_site/ posts.json (the posts' HTML), the studio's own pages (site/pages)
|
|
13
|
+
* site/dist/<file> site/public, as it is (fonts, a logo, hero footage)
|
|
10
14
|
* site/dist/music/..., site/dist/videos/... media files the site serves itself (R2 keys are served from R2)
|
|
11
15
|
*
|
|
12
16
|
* game.json "build" picks how a game becomes files (a ported game keeps its own shape):
|
|
@@ -22,7 +26,9 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statS
|
|
|
22
26
|
import { createRequire } from 'node:module';
|
|
23
27
|
import { join, relative } from 'node:path';
|
|
24
28
|
import { buildMedia } from './media.mjs';
|
|
29
|
+
import { buildSiteFiles, landingOf, readPosts, readTheme } from './site.mjs';
|
|
25
30
|
import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
|
|
31
|
+
import { SEAT_MAX } from '../worker/seats.mjs';
|
|
26
32
|
import { STUDIO_VERSION } from './version.mjs';
|
|
27
33
|
|
|
28
34
|
const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
|
|
@@ -76,6 +82,31 @@ export function sourceOf(dir, id) {
|
|
|
76
82
|
return { v: 1, kind: 'homie-game-source', id, files };
|
|
77
83
|
}
|
|
78
84
|
|
|
85
|
+
function readJson(path) {
|
|
86
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A game's netplay manifest: game.json's `netplay` block, over a `netplay.json` beside game.json or in the game's
|
|
91
|
+
* built output (a ported Vite game ships public/netplay.json), over nothing.
|
|
92
|
+
*/
|
|
93
|
+
export function netplayOf(g, out = null) {
|
|
94
|
+
const file = readJson(join(g.dir, 'netplay.json')) ?? (out ? readJson(join(out, 'netplay.json')) : null) ?? {};
|
|
95
|
+
return { ...(file && typeof file === 'object' ? file : {}), ...(g.netplay && typeof g.netplay === 'object' ? g.netplay : {}) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How many seats a room of this game has: the netplay manifest's `maxPlayers` (or `players.max`), else game.json's
|
|
100
|
+
* `players.max`, else 8; at most SEAT_MAX (32). `asked` is what the game named, so a build can say it was capped.
|
|
101
|
+
*/
|
|
102
|
+
export function seatsFor(g, net = netplayOf(g)) {
|
|
103
|
+
const named = [net.maxPlayers, net.players?.max, g.players?.max].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
|
|
104
|
+
const asked = named ?? 8;
|
|
105
|
+
const max = Math.min(SEAT_MAX, asked);
|
|
106
|
+
const minNamed = [net.minPlayers, net.players?.min, g.players?.min].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
|
|
107
|
+
return { min: Math.min(max, minNamed ?? 1), max, asked };
|
|
108
|
+
}
|
|
109
|
+
|
|
79
110
|
export async function build(root, { only = null, log = () => {} } = {}) {
|
|
80
111
|
const require = createRequire(join(root, 'package.json'));
|
|
81
112
|
let esbuild;
|
|
@@ -134,7 +165,9 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
134
165
|
if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id))}\n`);
|
|
135
166
|
const main = join(out, 'assets', 'main.js');
|
|
136
167
|
const bytes = existsSync(main) ? statSync(main).size : dirBytes(out);
|
|
137
|
-
|
|
168
|
+
const seats = seatsFor(g, netplayOf(g, out));
|
|
169
|
+
if (seats.asked > SEAT_MAX) log(`warning: games/${g.id} asks for ${seats.asked} players; a room holds at most ${SEAT_MAX}, so its rooms have ${SEAT_MAX} seats`);
|
|
170
|
+
built.push({ id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max });
|
|
138
171
|
log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB)`);
|
|
139
172
|
}
|
|
140
173
|
const all = listGames(root);
|
|
@@ -145,19 +178,49 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
145
178
|
else {
|
|
146
179
|
try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [] }; } catch { media = { songs: [], videos: [], skipped: [] }; }
|
|
147
180
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
181
|
+
// The site around the games (site/SITE.md): the look, each game's landing, posts, and what site/ overrides.
|
|
182
|
+
const theme = readTheme(root, { log });
|
|
183
|
+
const shown = all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html')));
|
|
184
|
+
const rows = shown.map((g) => {
|
|
185
|
+
// The seats come from the game's netplay manifest (NETPLAY.md §3): what the Worker gives every room of it.
|
|
186
|
+
const net = netplayOf(g, join(dist, 'games', g.id));
|
|
187
|
+
const { min, max } = seatsFor(g, net);
|
|
188
|
+
return {
|
|
189
|
+
id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
|
|
190
|
+
roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
|
|
153
191
|
...(g.screen ? { screen: g.screen } : {}),
|
|
154
|
-
|
|
192
|
+
landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
const { posts, skipped: postsSkipped } = readPosts(root, { games: rows, songs: media.songs, videos: media.videos, log });
|
|
196
|
+
const site = buildSiteFiles(root, dist, { gameIds: rows.map((g) => g.id), log });
|
|
197
|
+
mkdirSync(join(dist, '_site'), { recursive: true });
|
|
198
|
+
writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
|
|
199
|
+
const s = studio.site && typeof studio.site === 'object' ? studio.site : {};
|
|
200
|
+
const catalogue = {
|
|
201
|
+
studio: {
|
|
202
|
+
name: studio.name, slug: studio.slug, version: STUDIO_VERSION,
|
|
203
|
+
...(typeof studio.tagline === 'string' && studio.tagline.trim() ? { tagline: studio.tagline.trim().slice(0, 140) } : {}),
|
|
204
|
+
theme,
|
|
205
|
+
site: {
|
|
206
|
+
...(rows.some((g) => g.id === s.featured) ? { featured: s.featured } : {}),
|
|
207
|
+
...(Array.isArray(s.frameAncestors) ? { frameAncestors: s.frameAncestors.filter((o) => /^https:\/\/[a-z0-9.-]+(?::\d+)?$/i.test(String(o))).slice(0, 8) } : {}),
|
|
208
|
+
},
|
|
209
|
+
// studio.json `stats.share`: the site tells the directory two numbers for the hub (played this week).
|
|
210
|
+
...(studio.stats?.share === true ? { stats: { share: true }, directory: studio.homie?.directory ?? 'https://homie.rocks' } : {}),
|
|
211
|
+
},
|
|
212
|
+
games: rows,
|
|
155
213
|
songs: media.songs,
|
|
156
214
|
videos: media.videos,
|
|
215
|
+
// Summaries only; each post's HTML is in _site/posts.json.
|
|
216
|
+
posts: posts.map(({ html, record, ...p }) => p),
|
|
217
|
+
site: { pages: site.pages, partials: site.partials, ...(site.css ? { css: site.css } : {}) },
|
|
157
218
|
};
|
|
158
219
|
writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
|
|
159
220
|
return {
|
|
160
221
|
ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
|
|
161
222
|
songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped,
|
|
223
|
+
posts: posts.map((p) => p.slug), postsSkipped, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
|
|
224
|
+
landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length), source: g.landing.source })),
|
|
162
225
|
};
|
|
163
226
|
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -22,8 +22,8 @@ import { spawnSync } from 'node:child_process';
|
|
|
22
22
|
import { existsSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { build } from './build.mjs';
|
|
25
|
-
import { wranglerConfig } from './scaffold.mjs';
|
|
26
|
-
import { readStudio, writeStudio } from './studio.mjs';
|
|
25
|
+
import { ensureLocalIgnored, ensureStatsMigration, wranglerConfig } from './scaffold.mjs';
|
|
26
|
+
import { LOCAL_STATE, isWorkersDev, readLocal, readStudio, siteUrl, writeLocal, writeStudio } from './studio.mjs';
|
|
27
27
|
|
|
28
28
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
29
29
|
|
|
@@ -32,7 +32,8 @@ export function wranglerBin(root) {
|
|
|
32
32
|
return existsSync(local) ? local : null;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
/** Wrangler from the studio's own node_modules, run in site/ with the given environment. */
|
|
36
|
+
export function runner(root, env) {
|
|
36
37
|
const bin = wranglerBin(root);
|
|
37
38
|
if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
|
|
38
39
|
return (args, { cwd = join(root, 'site'), input } = {}) => {
|
|
@@ -95,7 +96,7 @@ export function deployPlan(root) {
|
|
|
95
96
|
ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
|
|
96
97
|
cloudflare: [
|
|
97
98
|
{ kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`), plan: 'Workers Free' },
|
|
98
|
-
{ kind: 'D1 database', name: cf.d1, what: 'the directory claim
|
|
99
|
+
{ kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, and the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor)', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
|
|
99
100
|
{ kind: 'Durable Object', name: 'Table', what: 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
100
101
|
{ kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
101
102
|
storage
|
|
@@ -144,7 +145,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
144
145
|
if (!created.has(`worker:${cf.worker}`)) {
|
|
145
146
|
const account = who.accounts.find((a) => a.id === accountId)?.name ?? 'the signed-in account';
|
|
146
147
|
announced.push(
|
|
147
|
-
`First deploy of ${studio.name} to the Cloudflare account "${account}". It creates: the Worker ${cf.worker} (the site and its rooms), the D1 database ${cf.d1}, and the Durable Objects Table and Lobby (SQLite-backed).`,
|
|
148
|
+
`First deploy of ${studio.name} to the Cloudflare account "${account}". It creates: the Worker ${cf.worker} (the site and its rooms), the D1 database ${cf.d1} (rounds, and the studio's own stats: counts for the owner, never a visitor's identity), and the Durable Objects Table and Lobby (SQLite-backed).`,
|
|
148
149
|
'Cost: free, on the Workers Free plan; no payment method, no R2 (storage for large media is `homie-studio storage add`, later, only if wanted).',
|
|
149
150
|
`The directory (${homie || studio.homie?.directory || 'https://homie.rocks'}) will store the site's address and claim, the studio's name, and each game's name, blurb and Play link.`,
|
|
150
151
|
);
|
|
@@ -186,6 +187,8 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
186
187
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
187
188
|
|
|
188
189
|
writeFileSync(join(root, 'site', 'wrangler.jsonc'), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2 }));
|
|
190
|
+
const added = ensureStatsMigration(root);
|
|
191
|
+
if (added) step(`added ${added} (the studio's own stats: counts, never tracks)`);
|
|
189
192
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
190
193
|
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
191
194
|
step('D1 migrations applied');
|
|
@@ -194,10 +197,22 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
194
197
|
const dep = w(['deploy']);
|
|
195
198
|
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
|
|
196
199
|
remember(`worker:${cf.worker}`);
|
|
197
|
-
|
|
198
|
-
|
|
200
|
+
// The workers.dev address names the Cloudflare account (often after its owner): it stays on this computer, in
|
|
201
|
+
// .studio/local.json (git-ignored), and never in the committed studio.json. A custom domain stays in studio.json.
|
|
202
|
+
if (ensureLocalIgnored(root)) step(`added .studio/ to .gitignore (${LOCAL_STATE} keeps this computer's own state)`);
|
|
203
|
+
const workersDev = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? readLocal(root).url ?? (isWorkersDev(cf.url) ? cf.url : null);
|
|
204
|
+
if (workersDev) writeLocal(root, { url: workersDev, deployedAt: new Date().toISOString() });
|
|
205
|
+
const movedOut = isWorkersDev(cf.url);
|
|
206
|
+
if (movedOut) {
|
|
207
|
+
cf.url = null;
|
|
208
|
+
writeStudio(root, { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, url: null } });
|
|
209
|
+
step(`studio.json no longer keeps the workers.dev address (it names the Cloudflare account); it is in ${LOCAL_STATE} on this computer. An earlier commit of studio.json still has it in the repository's history.`);
|
|
210
|
+
}
|
|
211
|
+
const url = siteUrl(root);
|
|
212
|
+
step(`deployed ${cf.worker} in ${Math.round((Date.now() - started) / 1000)} s`, { url, ...(workersDev && workersDev !== url ? { workersDev } : {}) });
|
|
199
213
|
|
|
200
|
-
// The homie.rocks directory claim
|
|
214
|
+
// The homie.rocks directory claim, for the site's public address (its own domain when it has one): the site
|
|
215
|
+
// serves it, which proves this studio controls the site.
|
|
201
216
|
const directory = homie || studio.homie?.directory || 'https://homie.rocks';
|
|
202
217
|
let claim = null;
|
|
203
218
|
if (url) {
|
|
@@ -212,10 +227,12 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
212
227
|
}
|
|
213
228
|
}
|
|
214
229
|
|
|
215
|
-
|
|
216
|
-
|
|
230
|
+
// studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
|
|
231
|
+
const { url: _url, deployedAt: _at, ...kept } = cf;
|
|
232
|
+
const next = { ...kept, ...(cf.url && !isWorkersDev(cf.url) ? { url: cf.url } : {}), accountId, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...created].sort() };
|
|
233
|
+
writeStudio(root, { ...readStudio(root), cloudflare: next });
|
|
217
234
|
return {
|
|
218
|
-
ok: true, command: 'deploy', url, worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
|
|
235
|
+
ok: true, command: 'deploy', url, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
|
|
219
236
|
games: b.catalogue.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })),
|
|
220
237
|
songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
|
|
221
238
|
videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
|
package/lib/directory.mjs
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
* claim the directory gave it (`homie-studio deploy` stores it in D1), so
|
|
5
5
|
* nobody can list games under someone else's site.
|
|
6
6
|
*/
|
|
7
|
-
import { readStudio } from './studio.mjs';
|
|
7
|
+
import { readStudio, siteUrl } from './studio.mjs';
|
|
8
8
|
|
|
9
9
|
export async function publish(root, { homie, site } = {}) {
|
|
10
10
|
const studio = readStudio(root);
|
|
11
11
|
const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
12
|
-
const url = site || studio
|
|
12
|
+
const url = site || siteUrl(root, studio);
|
|
13
13
|
if (!url) return { ok: false, command: 'publish', why: 'this studio has no live site yet: run `npm run deploy` first' };
|
|
14
14
|
const res = await fetch(`${directory}/api/studio/publish`, {
|
|
15
15
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url }),
|
package/lib/look.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio look [<path>...] --url <site> [--shots <dir>]` — the studio's pages as a stranger sees them:
|
|
3
|
+
* each page on a computer (1440 x 900), a phone held upright (390 x 844) and the phone turned sideways
|
|
4
|
+
* (844 x 390), scrolled to the end so everything that rises into view has, then shot whole and above the fold.
|
|
5
|
+
* Measured on every shot: the answer's status, anything wider than the screen, whether the Play button is on the
|
|
6
|
+
* first screen (Home and a game's landing), pictures that did not load, footage that did not play, and script errors. Pictures go to
|
|
7
|
+
* `--shots` (default .studio/look/), which git ignores. One Chrome, closed at the end; its visits are house QA
|
|
8
|
+
* (the studio's stats leave them out).
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const CHROMES = [
|
|
15
|
+
process.env.CHROME_PATH,
|
|
16
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
17
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
18
|
+
'/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
19
|
+
].filter(Boolean);
|
|
20
|
+
|
|
21
|
+
export const LOOK_DEVICES = {
|
|
22
|
+
computer: { width: 1440, height: 900, deviceScaleFactor: 1 },
|
|
23
|
+
phone: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
|
|
24
|
+
sideways: { width: 844, height: 390, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
28
|
+
const nameOf = (path) => path.replace(/^\/+|\/+$/g, '').replace(/[^a-z0-9]+/gi, '-') || 'home';
|
|
29
|
+
|
|
30
|
+
export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVICES), log = () => {} }) {
|
|
31
|
+
if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
|
|
32
|
+
const chrome = CHROMES.find((p) => existsSync(p));
|
|
33
|
+
if (!chrome) return { ok: false, command: 'look', why: 'no Chrome found (set CHROME_PATH)' };
|
|
34
|
+
let puppeteer;
|
|
35
|
+
try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'look', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
|
|
36
|
+
const base = String(url).replace(/\/+$/, '');
|
|
37
|
+
mkdirSync(shots, { recursive: true });
|
|
38
|
+
const profile = mkdtempSync(join(tmpdir(), 'homie-studio-look-'));
|
|
39
|
+
const browser = await puppeteer.launch({
|
|
40
|
+
executablePath: chrome, headless: true, userDataDir: profile,
|
|
41
|
+
args: ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
|
|
42
|
+
});
|
|
43
|
+
const rows = [];
|
|
44
|
+
try {
|
|
45
|
+
for (const path of paths) {
|
|
46
|
+
for (const device of devices) {
|
|
47
|
+
const page = await browser.newPage();
|
|
48
|
+
const errors = [];
|
|
49
|
+
page.on('pageerror', (e) => errors.push(String(e?.message ?? e).slice(0, 200)));
|
|
50
|
+
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text().slice(0, 200)); });
|
|
51
|
+
await page.setViewport(LOOK_DEVICES[device]);
|
|
52
|
+
await page.setUserAgent(`${await browser.userAgent()} homie-studio-check look`);
|
|
53
|
+
const t0 = Date.now();
|
|
54
|
+
const res = await page.goto(`${base}${path}`, { waitUntil: 'networkidle2', timeout: 45_000 }).catch((e) => { errors.push(`open: ${e.message}`); return null; });
|
|
55
|
+
const ms = Date.now() - t0;
|
|
56
|
+
await sleep(900);
|
|
57
|
+
const fold = await page.evaluate(() => {
|
|
58
|
+
// Home and a game's landing lead with Play; other pages (a post, the rooms) are not judged on it.
|
|
59
|
+
if (!['home', 'landing'].includes(document.body?.dataset?.page ?? '')) return { play: null };
|
|
60
|
+
const play = [...document.querySelectorAll('.play')].find((a) => { const r = a.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
|
|
61
|
+
const r = play?.getBoundingClientRect();
|
|
62
|
+
return { play: r ? r.bottom <= innerHeight && r.top >= 0 : false };
|
|
63
|
+
});
|
|
64
|
+
const foldFile = join(shots, `${nameOf(path)}-${device}-fold.png`);
|
|
65
|
+
await page.screenshot({ path: foldFile });
|
|
66
|
+
await page.evaluate(async () => { for (let y = 0; y < document.documentElement.scrollHeight; y += Math.round(innerHeight * 0.7)) { window.scrollTo({ top: y, behavior: 'instant' }); await new Promise((r) => setTimeout(r, 110)); } window.scrollTo({ top: 0, behavior: 'instant' }); });
|
|
67
|
+
await sleep(900);
|
|
68
|
+
const facts = await page.evaluate(() => ({
|
|
69
|
+
title: document.title,
|
|
70
|
+
wide: [...document.querySelectorAll('body *')].filter((e) => { const b = e.getBoundingClientRect(); return b.width > 0 && b.right > innerWidth + 1 && !e.closest('[data-hero-media],.nav,pre,.hero-media'); }).slice(0, 5).map((e) => `${e.tagName.toLowerCase()}${e.className ? `.${String(e.className).split(' ')[0]}` : ''}`),
|
|
71
|
+
brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0 && i.getAttribute('src')).map((i) => i.getAttribute('src')).slice(0, 5),
|
|
72
|
+
footage: [...document.querySelectorAll('[data-hero-media] video')].map((v) => ({ playing: !v.paused && v.readyState >= 2, src: (v.currentSrc || '').split('/').pop() })),
|
|
73
|
+
height: document.documentElement.scrollHeight,
|
|
74
|
+
}));
|
|
75
|
+
const file = join(shots, `${nameOf(path)}-${device}.png`);
|
|
76
|
+
await page.screenshot({ path: file, fullPage: true });
|
|
77
|
+
const row = {
|
|
78
|
+
path, device, status: res?.status() ?? null, ms, title: facts.title, height: facts.height, playAboveFold: fold.play,
|
|
79
|
+
tooWide: facts.wide, brokenImages: facts.brokenImages, footage: facts.footage, errors, shot: file, fold: foldFile,
|
|
80
|
+
};
|
|
81
|
+
const bad = [];
|
|
82
|
+
if (!row.status || row.status >= 400) bad.push(`answered ${row.status}`);
|
|
83
|
+
if (row.tooWide.length) bad.push(`wider than the screen: ${row.tooWide.join(', ')}`);
|
|
84
|
+
if (row.playAboveFold === false && device !== 'sideways') bad.push('Play is not on the first screen');
|
|
85
|
+
if (row.brokenImages.length) bad.push(`pictures that did not load: ${row.brokenImages.join(', ')}`);
|
|
86
|
+
if (row.footage.some((f) => !f.playing)) bad.push('the hero footage did not play');
|
|
87
|
+
if (errors.length) bad.push(`${errors.length} script error(s): ${errors[0]}`);
|
|
88
|
+
row.ok = bad.length === 0;
|
|
89
|
+
row.problems = bad;
|
|
90
|
+
rows.push(row);
|
|
91
|
+
log(`${row.ok ? 'ok ' : 'LOOK'} ${path} ${device}: ${row.status} in ${ms} ms${bad.length ? ` · ${bad.join(' · ')}` : ''}`);
|
|
92
|
+
await page.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
await browser.close().catch(() => {});
|
|
97
|
+
rmSync(profile, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
writeFileSync(join(shots, 'look.json'), `${JSON.stringify(rows, null, 2)}\n`);
|
|
100
|
+
return { ok: rows.every((r) => r.ok), command: 'look', url: base, shots, rows };
|
|
101
|
+
}
|