@homie-rocks/studio 0.3.0 → 0.6.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 +76 -11
- package/bin/homie-studio.mjs +143 -29
- package/lib/build.mjs +61 -9
- package/lib/cloudflare.mjs +170 -35
- package/lib/directory.mjs +2 -2
- package/lib/media.mjs +126 -0
- package/lib/scaffold.mjs +87 -20
- package/lib/stats.mjs +125 -0
- package/lib/studio.mjs +43 -1
- package/media/MEDIA.md +81 -0
- package/netplay/NETPLAY.md +55 -12
- package/netplay/netplay.ts +19 -2
- package/package.json +3 -2
- package/worker/index.mjs +238 -29
- package/worker/pages.mjs +131 -3
- package/worker/room.mjs +9 -2
- package/worker/seats.mjs +20 -0
- package/worker/stats-page.mjs +144 -0
- package/worker/stats.mjs +311 -0
package/lib/scaffold.mjs
CHANGED
|
@@ -7,19 +7,23 @@
|
|
|
7
7
|
* studio.json the studio's name, slug, and its Cloudflare resources
|
|
8
8
|
* package.json @homie-rocks/studio and wrangler, pinned
|
|
9
9
|
* games/<id>/ one folder per game (game.json, index.html, src/)
|
|
10
|
-
* music/ videos/ manifests in the repo; the big files
|
|
10
|
+
* music/ videos/ manifests in the repo; the big files go to the studio's own storage
|
|
11
|
+
* (R2) once it is added with `homie-studio storage add`
|
|
11
12
|
* posts/ words the studio publishes
|
|
12
|
-
* site/ the studio's Worker (pages, rooms), its D1 migrations
|
|
13
|
+
* site/ the studio's Worker (pages, rooms), its D1 migrations (no R2
|
|
14
|
+
* binding until the studio adds storage: a free Cloudflare account
|
|
15
|
+
* without a payment method cannot use R2, and a new studio needs none)
|
|
13
16
|
* .claude/skills/ skills only this studio uses
|
|
14
17
|
*
|
|
15
18
|
* It writes only into a folder that is new or empty, never guesses one, and
|
|
16
19
|
* lists every file it writes, so the person sees exactly what changed.
|
|
17
20
|
*/
|
|
18
21
|
import { execFileSync } from 'node:child_process';
|
|
19
|
-
import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
20
23
|
import { homedir } from 'node:os';
|
|
21
24
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
22
25
|
import { STUDIO_VERSION, packageSpec } from './version.mjs';
|
|
26
|
+
import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
|
|
23
27
|
|
|
24
28
|
export const COMPAT_DATE = '2026-06-01';
|
|
25
29
|
export const WRANGLER_VERSION = '4.126.0';
|
|
@@ -47,7 +51,8 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null }) {
|
|
|
47
51
|
observability: { enabled: true },
|
|
48
52
|
};
|
|
49
53
|
return `// This studio's site on its own Cloudflare account (written by homie-studio; \`homie-studio deploy\`
|
|
50
|
-
// fills in the D1 id
|
|
54
|
+
// fills in the D1 id). Everything here runs on Cloudflare's Workers Free plan. There is no R2 binding
|
|
55
|
+
// until \`homie-studio storage add\` gives the studio storage for large media.
|
|
51
56
|
${JSON.stringify(config, null, 2)}
|
|
52
57
|
`;
|
|
53
58
|
}
|
|
@@ -55,22 +60,22 @@ ${JSON.stringify(config, null, 2)}
|
|
|
55
60
|
function agentsMd({ name, slug }) {
|
|
56
61
|
return `# ${name}
|
|
57
62
|
|
|
58
|
-
This folder is a
|
|
63
|
+
This folder is a studio: **${name}** (\`${slug}\`). It is one repository.
|
|
59
64
|
Its games, music, videos and posts live here; its website and public game rooms
|
|
60
|
-
run on the studio's **own Cloudflare account** (
|
|
61
|
-
Table/Lobby Durable Objects
|
|
62
|
-
\`package.json\`. The homie.rocks
|
|
63
|
-
not host them.
|
|
65
|
+
run on the studio's **own Cloudflare account** (one Worker, one D1 database and
|
|
66
|
+
the Table/Lobby Durable Objects, all on Cloudflare's free Workers plan), built
|
|
67
|
+
from \`@homie-rocks/studio\`, pinned in \`package.json\`. The homie.rocks
|
|
68
|
+
directory lists its games; homie.rocks does not host them.
|
|
64
69
|
|
|
65
70
|
## Layout
|
|
66
71
|
|
|
67
72
|
| Path | What it is |
|
|
68
73
|
| --- | --- |
|
|
69
74
|
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`. |
|
|
70
|
-
| \`music/\`, \`videos/\` | \`manifest.json\` lists each
|
|
75
|
+
| \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`, served from the site itself (files up to 25 MiB) or, for larger media, from the studio's storage once it has storage (see below; \`npx --no-install homie-studio media put <file>\`). Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
|
|
71
76
|
| \`posts/\` | Markdown the studio publishes. |
|
|
72
77
|
| \`site/\` | The studio's Worker (\`src/worker.mjs\`), D1 migrations, \`wrangler.jsonc\`. |
|
|
73
|
-
| \`studio.json\` | The studio's name, slug
|
|
78
|
+
| \`studio.json\` | The studio's name, slug, Cloudflare resource names, custom domain and stats sharing. \`.studio/\` (git-ignored) is this computer's own state. |
|
|
74
79
|
| \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
|
|
75
80
|
|
|
76
81
|
## Commands (all through the pinned CLI in node_modules)
|
|
@@ -87,13 +92,29 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
87
92
|
- \`npm run build\` — bundle every game into \`site/dist\`.
|
|
88
93
|
- \`npm run dev\` — the whole site locally (pages, the netplay relay in a local
|
|
89
94
|
Durable Object, D1): open the printed address in two browsers and they share a room.
|
|
95
|
+
Stop it with \`npx --no-install homie-studio dev --stop\` (this studio's dev server only;
|
|
96
|
+
never \`pkill\` by name, which stops other projects' dev servers too).
|
|
90
97
|
- \`npx --no-install homie-studio check <id> --url <site>\` — two headless browsers press Play and must
|
|
91
98
|
land in the same room and finish a round. Run it before you say a game works.
|
|
92
|
-
- \`
|
|
99
|
+
- \`npx --no-install homie-studio deploy --plan\` — says what deploy will create on Cloudflare and what it
|
|
100
|
+
costs, and changes nothing. Tell the person before the first deploy.
|
|
101
|
+
- \`npm run deploy\` — the site on this studio's Cloudflare: one Worker, one D1 database, two
|
|
102
|
+
SQLite-backed Durable Objects, all on the free Workers plan (no payment method needed).
|
|
93
103
|
If Wrangler is not signed in, run \`npx wrangler login\`: the person approves once in
|
|
94
104
|
their browser. It never overwrites a Worker or database this studio did not create.
|
|
105
|
+
- \`npx --no-install homie-studio storage add\` — only when the studio needs large media (songs,
|
|
106
|
+
videos): an R2 bucket for \`media put\`, served at \`/media/<key>\`. Cloudflare asks for a
|
|
107
|
+
payment method on the account before R2 works (its first 10 GB a month are free), so this
|
|
108
|
+
is a separate step the person agrees to; nothing else needs it.
|
|
95
109
|
- \`npx --no-install homie-studio publish\` — list this studio's games in the homie.rocks directory
|
|
96
110
|
(or call the Homie MCP tool \`studio_publish\`).
|
|
111
|
+
- \`npx --no-install homie-studio stats\` — the studio's own numbers, for its owner: visits, Play presses,
|
|
112
|
+
rooms, the most people playing at once, rounds, songs played, videos watched, and where visitors came
|
|
113
|
+
from (homie.rocks, other studios, search, the web). \`stats link\` gives the owner a one-time link to the
|
|
114
|
+
private page \`/_studio/stats\` in their own browser; \`stats key\` a short read key for the Homie MCP tool
|
|
115
|
+
\`studio_stats\` (never paste a key anywhere else). \`stats share on\` tells the directory two numbers
|
|
116
|
+
(played this week). The site counts and never tracks: no cookie on a visitor, no person identified,
|
|
117
|
+
nothing sent anywhere; house QA and \`check\` runs are not counted.
|
|
97
118
|
|
|
98
119
|
## Making games
|
|
99
120
|
|
|
@@ -112,7 +133,17 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
112
133
|
- Keys stay in the providers' own logins (Wrangler, ElevenLabs, fal) or the OS
|
|
113
134
|
keychain. Never write a key, token or password into this repository.
|
|
114
135
|
- Never touch a Cloudflare resource this studio did not create (\`studio.json\` says which).
|
|
136
|
+
- The site's workers.dev address names the Cloudflare account (often after its owner): \`deploy\` keeps it in
|
|
137
|
+
\`.studio/local.json\`, which git ignores. Never copy it into a committed file. A custom domain goes in
|
|
138
|
+
studio.json as \`cloudflare.domain\`.
|
|
139
|
+
- A game's room size is its netplay manifest's \`maxPlayers\` (game.json \`netplay\`, or netplay.json), up to 32.
|
|
115
140
|
- Nothing in this studio needs \`~/.homie\` or a Homie box.
|
|
141
|
+
|
|
142
|
+
## Beta
|
|
143
|
+
|
|
144
|
+
Homie for studios is in beta. When something breaks, or a game you want to port does
|
|
145
|
+
not fit, open an issue at https://github.com/homie-rocks/homie/issues/new/choose (a bug,
|
|
146
|
+
a port request or a question). Leave keys, tokens and private addresses out of it.
|
|
116
147
|
`;
|
|
117
148
|
}
|
|
118
149
|
|
|
@@ -151,6 +182,8 @@ CREATE INDEX IF NOT EXISTS rounds_game_at ON rounds (game, at);
|
|
|
151
182
|
`;
|
|
152
183
|
|
|
153
184
|
const GITIGNORE = `node_modules/
|
|
185
|
+
# This computer's own state: the site's workers.dev address (it names the Cloudflare account, often after its owner).
|
|
186
|
+
.studio/
|
|
154
187
|
site/dist/
|
|
155
188
|
site/.wrangler/
|
|
156
189
|
.wrangler/
|
|
@@ -159,7 +192,10 @@ site/.wrangler/
|
|
|
159
192
|
*.log
|
|
160
193
|
# Port checks: receipts and screenshots of each run (games/<id>/.port/check-*/).
|
|
161
194
|
games/*/.port/
|
|
162
|
-
# Large media lives in this studio's R2; the manifests beside it are committed.
|
|
195
|
+
# Large media lives in this studio's storage (R2, after storage add) or is served by the site; the manifests beside it are committed.
|
|
196
|
+
# Working files of the music and video skills (frames, captures, provider answers) stay on this computer.
|
|
197
|
+
music/**/work/
|
|
198
|
+
videos/**/work/
|
|
163
199
|
music/**/*.wav
|
|
164
200
|
music/**/*.mp3
|
|
165
201
|
music/**/*.flac
|
|
@@ -174,7 +210,13 @@ export function studioFiles({ name, slug, homie }) {
|
|
|
174
210
|
const studio = {
|
|
175
211
|
name, slug,
|
|
176
212
|
homie: { studio: STUDIO_VERSION, directory: homie },
|
|
177
|
-
|
|
213
|
+
// r2 stays null until `homie-studio storage add`: a new studio deploys with no R2 at all.
|
|
214
|
+
// `domain`: the studio's own domain once it has one (e.g. "night-owls.example"). The workers.dev address never
|
|
215
|
+
// goes here: it names the Cloudflare account, so deploy keeps it in .studio/local.json (git-ignored).
|
|
216
|
+
cloudflare: { worker, d1: `${slug}-db`, r2: null, accountId: null, domain: null, created: [] },
|
|
217
|
+
// The site counts visits, plays, rooms, rounds and songs for the owner only (`homie-studio stats`); `share`
|
|
218
|
+
// also tells the homie.rocks directory "played this week" (two numbers) for the hub.
|
|
219
|
+
stats: { share: false },
|
|
178
220
|
};
|
|
179
221
|
const pkg = {
|
|
180
222
|
name: `${slug}-studio`,
|
|
@@ -191,21 +233,46 @@ export function studioFiles({ name, slug, homie }) {
|
|
|
191
233
|
'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
|
|
192
234
|
'.gitignore': GITIGNORE,
|
|
193
235
|
'games/README.md': 'One folder per game. Start one with `npx --no-install homie-studio game new <id> --from gem-rush`.\n',
|
|
194
|
-
'music/README.md': 'Songs and stems. `manifest.json` lists them;
|
|
195
|
-
'music/manifest.json': '{ "items": [] }\n',
|
|
196
|
-
'videos/README.md': 'Trailers and
|
|
197
|
-
'videos/manifest.json': '{ "items": [] }\n',
|
|
236
|
+
'music/README.md': 'Songs, game scores, loops and stems, one folder each (`music/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/music/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
|
|
237
|
+
'music/manifest.json': '{ "v": 1, "items": [] }\n',
|
|
238
|
+
'videos/README.md': 'Trailers, music videos and cutscenes, one folder each (`videos/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/videos/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
|
|
239
|
+
'videos/manifest.json': '{ "v": 1, "items": [] }\n',
|
|
198
240
|
'posts/README.md': 'Markdown posts this studio publishes, one file each.\n',
|
|
199
|
-
'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1 and R2.
|
|
241
|
+
'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
|
|
200
242
|
// The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
|
|
201
243
|
export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
202
244
|
`,
|
|
203
245
|
'site/migrations/0001_studio.sql': MIGRATION,
|
|
246
|
+
[`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
|
|
204
247
|
'site/wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2 }),
|
|
205
248
|
'.claude/skills/.gitkeep': '',
|
|
206
249
|
};
|
|
207
250
|
}
|
|
208
251
|
|
|
252
|
+
/**
|
|
253
|
+
* A studio made before 0.6.0 has no stats migration: add it (the template owns site/migrations/), so the next
|
|
254
|
+
* `d1 migrations apply` makes the counters. Returns the file it wrote, or null when it was there.
|
|
255
|
+
*/
|
|
256
|
+
export function ensureStatsMigration(root) {
|
|
257
|
+
const file = join(root, 'site', 'migrations', STATS_MIGRATION_FILE);
|
|
258
|
+
if (existsSync(file)) return null;
|
|
259
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
260
|
+
writeFileSync(file, STATS_MIGRATION);
|
|
261
|
+
return `site/migrations/${STATS_MIGRATION_FILE}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A studio made before 0.6.0 committed nothing under .studio/ yet: make sure git leaves it out before the first
|
|
266
|
+
* deploy writes the workers.dev address there. Returns true when it added the line.
|
|
267
|
+
*/
|
|
268
|
+
export function ensureLocalIgnored(root) {
|
|
269
|
+
const file = join(root, '.gitignore');
|
|
270
|
+
const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
271
|
+
if (/^\/?\.studio\/?$/m.test(text)) return false;
|
|
272
|
+
writeFileSync(file, `${text}${text && !text.endsWith('\n') ? '\n' : ''}# This computer's own state: the site's workers.dev address (it names the Cloudflare account).\n.studio/\n`);
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
209
276
|
function insideGit(dir) {
|
|
210
277
|
try { return execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() === 'true'; } catch { return false; }
|
|
211
278
|
}
|
|
@@ -254,7 +321,7 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
|
|
|
254
321
|
'npx --no-install homie-studio game new <id> --from gem-rush --name "<Game Name>"',
|
|
255
322
|
'npm run dev (then: npx --no-install homie-studio check <id> --url http://127.0.0.1:8787)',
|
|
256
323
|
'npm run deploy (then the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish)',
|
|
257
|
-
] };
|
|
324
|
+
], online: 'Going online creates one Worker, one D1 database and two Durable Objects on your own Cloudflare account: free plan, no payment method, no R2. `npx --no-install homie-studio deploy --plan` says exactly what, and changes nothing.' };
|
|
258
325
|
}
|
|
259
326
|
|
|
260
327
|
export const _test = { relative };
|
package/lib/stats.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio stats` — the studio's own numbers, for its owner (worker/stats.mjs says what is counted).
|
|
3
|
+
*
|
|
4
|
+
* homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>]
|
|
5
|
+
* the numbers, read with a key that lives ten minutes and is dropped after
|
|
6
|
+
* homie-studio stats key [--hours 1] a read key for the Homie MCP tool `studio_stats` (up to 30 days)
|
|
7
|
+
* homie-studio stats link a one-time link that opens /_studio/stats in the owner's browser
|
|
8
|
+
* homie-studio stats revoke every key and page session ends now
|
|
9
|
+
* homie-studio stats share on|off tell the homie.rocks directory "played this week" (two numbers), or stop
|
|
10
|
+
*
|
|
11
|
+
* THE PROOF OF OWNERSHIP IS THE STUDIO'S OWN CLOUDFLARE LOGIN. A key is minted here, on this computer, and only
|
|
12
|
+
* its SHA-256 goes into the studio's D1 (through Wrangler, which only the studio's owner is signed in to). The key
|
|
13
|
+
* itself is never sent to Cloudflare, never written to a file, and lives for its hours only. A studio running
|
|
14
|
+
* locally (`npm run dev`, a loopback --url) uses the local D1.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
17
|
+
import { runner } from './cloudflare.mjs';
|
|
18
|
+
import { readStudio, siteUrl, writeStudio } from './studio.mjs';
|
|
19
|
+
|
|
20
|
+
const HOUR = 3600_000;
|
|
21
|
+
const RANGES = new Set(['1d', '7d', '30d', '90d']);
|
|
22
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
23
|
+
|
|
24
|
+
const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
|
|
25
|
+
|
|
26
|
+
function siteOf(root, url) {
|
|
27
|
+
const studio = readStudio(root);
|
|
28
|
+
const site = String(url || siteUrl(root, studio) || '').replace(/\/+$/, '');
|
|
29
|
+
if (!/^https?:\/\//.test(site)) return { studio, site: null };
|
|
30
|
+
return { studio, site };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Put a key's hash into the studio's D1 (remote, or local for a loopback site). The key never leaves this machine. */
|
|
34
|
+
function mint(root, studio, kind, ttlMs, { local }) {
|
|
35
|
+
const key = `hsk_${randomBytes(24).toString('hex')}`;
|
|
36
|
+
const hash = createHash('sha256').update(key).digest('hex');
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
39
|
+
const sql = `DELETE FROM stats_keys WHERE expires_at < ${now}; INSERT INTO stats_keys (hash, kind, expires_at) VALUES ('${hash}', '${kind}', ${now + ttlMs});`;
|
|
40
|
+
const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', sql]);
|
|
41
|
+
if (res.code !== 0) {
|
|
42
|
+
const why = /no such table: stats_keys/i.test(res.out)
|
|
43
|
+
? 'this studio\'s D1 has no stats tables yet: run `npm run deploy` (it applies migration 0002_studio_stats.sql), or `npm run dev` for a local site'
|
|
44
|
+
: /not logged in|login|authenticat/i.test(res.out) ? 'Wrangler is not signed in to this studio\'s Cloudflare: run `npx wrangler login` (the person approves once)'
|
|
45
|
+
: `could not write the key's hash to D1: ${res.out.trim().split('\n').slice(-3).join(' ')}`;
|
|
46
|
+
return { ok: false, why };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, key, hash, expiresAt: new Date(now + ttlMs).toISOString() };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function drop(root, studio, hash, { local }) {
|
|
52
|
+
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
53
|
+
w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', `DELETE FROM stats_keys WHERE hash = '${hash}';`]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function statsQuery({ range, game, song, video } = {}) {
|
|
57
|
+
const q = new URLSearchParams();
|
|
58
|
+
q.set('range', RANGES.has(range) ? range : '7d');
|
|
59
|
+
if (game && SLUG.test(game)) q.set('game', game);
|
|
60
|
+
else if (song && SLUG.test(song)) q.set('song', song);
|
|
61
|
+
else if (video && SLUG.test(video)) q.set('video', video);
|
|
62
|
+
return q;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The numbers, read with a ten-minute key that is dropped as soon as they are in. */
|
|
66
|
+
export async function statsShow(root, { url, range, game, song, video } = {}) {
|
|
67
|
+
const { studio, site } = siteOf(root, url);
|
|
68
|
+
if (!site) return { ok: false, command: 'stats', why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
|
|
69
|
+
const local = loopback(site);
|
|
70
|
+
const k = mint(root, studio, 'read', 10 * 60_000, { local });
|
|
71
|
+
if (!k.ok) return { ok: false, command: 'stats', why: k.why };
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(`${site}/api/stats?${statsQuery({ range, game, song, video })}`, { headers: { authorization: `Bearer ${k.key}` }, signal: AbortSignal.timeout(20_000) });
|
|
74
|
+
const body = await res.json().catch(() => ({}));
|
|
75
|
+
if (!res.ok || !body.ok) return { ok: false, command: 'stats', why: body.message ?? `${site}/api/stats answered ${res.status}` };
|
|
76
|
+
return { command: 'stats', ...body, ok: true };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { ok: false, command: 'stats', why: `could not read ${site}/api/stats: ${error.message}` };
|
|
79
|
+
} finally { drop(root, studio, k.hash, { local }); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A read key for `studio_stats` (the Homie MCP): 1 hour by default, 30 days at most. */
|
|
83
|
+
export function statsKey(root, { url, hours } = {}) {
|
|
84
|
+
const { studio, site } = siteOf(root, url);
|
|
85
|
+
if (!site) return { ok: false, command: 'stats key', why: 'this studio has no live site yet: `npm run deploy` first' };
|
|
86
|
+
const h = Math.max(1, Math.min(24 * 30, Math.floor(Number(hours) || 1)));
|
|
87
|
+
const k = mint(root, studio, 'read', h * HOUR, { local: loopback(site) });
|
|
88
|
+
if (!k.ok) return { ok: false, command: 'stats key', why: k.why };
|
|
89
|
+
return {
|
|
90
|
+
ok: true, command: 'stats key', site, key: k.key, expiresAt: k.expiresAt, hours: h,
|
|
91
|
+
use: `Call the Homie MCP tool studio_stats with { "site": "${site}", "key": "<this key>" } (and range, game, song or video). The key only reads this studio's numbers, ends at ${k.expiresAt}, and \`homie-studio stats revoke\` ends it sooner. Never paste it anywhere else.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A one-time sign-in link (30 minutes) that opens the private stats page in the owner's own browser. */
|
|
96
|
+
export function statsLink(root, { url } = {}) {
|
|
97
|
+
const { studio, site } = siteOf(root, url);
|
|
98
|
+
if (!site) return { ok: false, command: 'stats link', why: 'this studio has no live site yet: `npm run deploy` first' };
|
|
99
|
+
const k = mint(root, studio, 'signin', 30 * 60_000, { local: loopback(site) });
|
|
100
|
+
if (!k.ok) return { ok: false, command: 'stats link', why: k.why };
|
|
101
|
+
return {
|
|
102
|
+
ok: true, command: 'stats link', site, link: `${site}/_studio/signin?k=${k.key}`, expiresAt: k.expiresAt,
|
|
103
|
+
use: 'Give this link to the studio\'s owner to open in their own browser: it works once, within 30 minutes, and keeps that browser signed in to the stats page for 30 days. It is theirs alone; do not post it anywhere.',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function statsRevoke(root, { url } = {}) {
|
|
108
|
+
const { studio, site } = siteOf(root, url);
|
|
109
|
+
const local = site ? loopback(site) : false;
|
|
110
|
+
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
111
|
+
const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', 'DELETE FROM stats_keys;']);
|
|
112
|
+
return res.code === 0 ? { ok: true, command: 'stats revoke', site, message: 'Every stats key and page session has ended.' } : { ok: false, command: 'stats revoke', why: res.out.trim().split('\n').slice(-3).join(' ') };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function statsShare(root, on) {
|
|
116
|
+
const studio = readStudio(root);
|
|
117
|
+
const share = on === 'on' || on === true || on === 'true';
|
|
118
|
+
writeStudio(root, { ...studio, stats: { ...(studio.stats ?? {}), share } });
|
|
119
|
+
return {
|
|
120
|
+
ok: true, command: 'stats share', share,
|
|
121
|
+
message: share
|
|
122
|
+
? 'studio.json stats.share is on. After `npm run deploy`, the site tells the homie.rocks directory two numbers for the whole studio: Play presses and rounds with people in them, over the last 7 days. Nothing else leaves the studio.'
|
|
123
|
+
: 'studio.json stats.share is off. After `npm run deploy`, the site tells the directory nothing.',
|
|
124
|
+
};
|
|
125
|
+
}
|
package/lib/studio.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
|
|
9
9
|
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
10
|
export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
11
|
-
const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', 'well-known', 'index', 'play', 'studio', 'studios']);
|
|
11
|
+
const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts']);
|
|
12
12
|
|
|
13
13
|
export function findStudio(from = process.cwd()) {
|
|
14
14
|
let at = resolve(from);
|
|
@@ -29,6 +29,48 @@ export function requireStudio(from) {
|
|
|
29
29
|
export function readStudio(root) { return JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')); }
|
|
30
30
|
export function writeStudio(root, studio) { writeFileSync(join(root, 'studio.json'), `${JSON.stringify(studio, null, 2)}\n`); }
|
|
31
31
|
|
|
32
|
+
/*
|
|
33
|
+
* WHERE THE SITE'S ADDRESS LIVES. studio.json is committed, and a workers.dev address carries the Cloudflare
|
|
34
|
+
* account's subdomain, which is often the person's own name. So studio.json keeps only a CUSTOM domain
|
|
35
|
+
* (`cloudflare.domain`, e.g. "night-owls.example"); the workers.dev address `deploy` gets back stays on this
|
|
36
|
+
* computer in .studio/local.json, which the studio's .gitignore leaves out.
|
|
37
|
+
*/
|
|
38
|
+
export const LOCAL_STATE = '.studio/local.json';
|
|
39
|
+
export const isWorkersDev = (url) => { try { return /\.workers\.dev$/i.test(new URL(url).hostname); } catch { return false; } };
|
|
40
|
+
|
|
41
|
+
/** `cloudflare.domain` as an origin: "example.com" or "https://example.com/" become "https://example.com"; else null. */
|
|
42
|
+
export function domainOrigin(value) {
|
|
43
|
+
const raw = String(value ?? '').trim();
|
|
44
|
+
if (!raw) return null;
|
|
45
|
+
try {
|
|
46
|
+
const u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
|
|
47
|
+
if (u.protocol !== 'https:' || !u.hostname.includes('.') || isWorkersDev(u.origin)) return null;
|
|
48
|
+
return u.origin;
|
|
49
|
+
} catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readLocal(root) {
|
|
53
|
+
try { return JSON.parse(readFileSync(join(root, LOCAL_STATE), 'utf8')); } catch { return {}; }
|
|
54
|
+
}
|
|
55
|
+
export function writeLocal(root, patch) {
|
|
56
|
+
const path = join(root, LOCAL_STATE);
|
|
57
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
58
|
+
const next = { ...readLocal(root), ...patch };
|
|
59
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The studio site's public address: its custom domain (studio.json `cloudflare.domain`, or a non-workers.dev
|
|
65
|
+
* `cloudflare.url` an older studio wrote by hand), else the workers.dev address this computer's deploy got back,
|
|
66
|
+
* else an older studio.json's workers.dev `url` (until the next deploy moves it out).
|
|
67
|
+
*/
|
|
68
|
+
export function siteUrl(root, studio = readStudio(root)) {
|
|
69
|
+
const cf = studio.cloudflare ?? {};
|
|
70
|
+
const custom = domainOrigin(cf.domain) ?? (cf.url && !isWorkersDev(cf.url) ? domainOrigin(cf.url) : null);
|
|
71
|
+
return custom ?? readLocal(root).url ?? cf.url ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
32
74
|
export function listGames(root) {
|
|
33
75
|
const dir = join(root, 'games');
|
|
34
76
|
if (!existsSync(dir)) return [];
|
package/media/MEDIA.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Studio media: music/ and videos/
|
|
2
|
+
|
|
3
|
+
A studio's songs, scores, loops, trailers, music videos and cutscenes live in the studio
|
|
4
|
+
repository as folders plus one manifest per kind. The manifests are the contract between
|
|
5
|
+
whatever made the media (the Homie plugin's `music` and `video` skills, or a person) and the
|
|
6
|
+
studio site, which turns every published entry into a page:
|
|
7
|
+
|
|
8
|
+
| Manifest | Page | Listed in |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| `music/manifest.json` | `/music/<slug>/` (an audio player) | the home page, `/music/`, `/.well-known/homie-studio.json` `songs` |
|
|
11
|
+
| `videos/manifest.json` | `/videos/<slug>/` (a video player) | the home page, `/videos/`, `/.well-known/homie-studio.json` `videos` |
|
|
12
|
+
|
|
13
|
+
`homie-studio build` reads both manifests. `homie-studio media list` prints what the site will
|
|
14
|
+
show and why anything is left out.
|
|
15
|
+
|
|
16
|
+
## An entry
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"v": 1,
|
|
21
|
+
"items": [
|
|
22
|
+
{
|
|
23
|
+
"slug": "theme",
|
|
24
|
+
"kind": "song",
|
|
25
|
+
"title": "Night Owls Theme",
|
|
26
|
+
"blurb": "The studio's theme: a short sung hook over a driving synth line.",
|
|
27
|
+
"published": true,
|
|
28
|
+
"duration": 16.0,
|
|
29
|
+
"bpm": 120,
|
|
30
|
+
"key": "A minor",
|
|
31
|
+
"files": [
|
|
32
|
+
{ "role": "audio", "path": "music/theme/theme.mp3", "type": "audio/mpeg" },
|
|
33
|
+
{ "role": "cover", "path": "music/theme/cover.jpg", "type": "image/jpeg" },
|
|
34
|
+
{ "role": "loop", "path": "music/theme/theme-loop.ogg", "type": "audio/ogg", "bars": 8 },
|
|
35
|
+
{ "role": "master", "path": "music/theme/theme-master.wav", "type": "audio/wav", "public": false }
|
|
36
|
+
],
|
|
37
|
+
"lyrics": "…",
|
|
38
|
+
"credits": "Music made with Eleven Music (ElevenLabs).",
|
|
39
|
+
"rights": { "provider": "elevenlabs", "plan": "creator", "commercial": true, "attribution": null },
|
|
40
|
+
"for": { "game": "crown-thief" },
|
|
41
|
+
"made": { "provider": "elevenlabs", "model": "music_v2", "at": "2026-09-30T02:00:00Z", "receipt": "music/theme/receipt.json" }
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- The manifest's order is the site's order (the plugin's skills put a new entry first).
|
|
48
|
+
- `slug`: lowercase letters, digits and hyphens, up to 40; it is the page address and never changes
|
|
49
|
+
once published. `title` is required for a page; an entry without `slug` and `title` is kept in
|
|
50
|
+
the manifest but gets no page.
|
|
51
|
+
- `kind`: music is `song`, `score`, `loop`, `stem` or `sfx`; video is `trailer`, `music-video`,
|
|
52
|
+
`cutscene` or `clip`.
|
|
53
|
+
- `published`: only `true` entries get a page and a directory listing. Work in progress stays
|
|
54
|
+
`false`.
|
|
55
|
+
- `files[].role`: music uses `audio` (what the player plays), `cover`, `loop`, `stem`,
|
|
56
|
+
`master`; video uses `video` (the 16:9 cut the player plays), `vertical` (the 9:16 cut),
|
|
57
|
+
`poster`, `captions` (WebVTT), `master`. `"public": false` keeps a file off the site (masters,
|
|
58
|
+
raw captures). Loops and stems are offered for download on the song page, so another game can
|
|
59
|
+
use them.
|
|
60
|
+
- `rights` and `credits` are shown on the page, in plain words. Say what the provider's terms
|
|
61
|
+
say for the plan the file was made on; nothing on the site claims more.
|
|
62
|
+
|
|
63
|
+
## Where the bytes come from
|
|
64
|
+
|
|
65
|
+
A studio needs no storage for songs and videos: the site serves them itself. For every public
|
|
66
|
+
file of a published entry, the build picks the first that exists:
|
|
67
|
+
|
|
68
|
+
1. `key`: the file is in the studio's storage, an R2 bucket that `homie-studio storage add`
|
|
69
|
+
creates (Cloudflare asks for a payment method on the account before R2 works, so the person
|
|
70
|
+
agrees first) and `homie-studio media put <file>` uploads to; the site serves it at
|
|
71
|
+
`/media/<key>`, with byte ranges so phones can seek.
|
|
72
|
+
2. `path`: the file is in the studio folder; the build copies it into the site as `/<path>` when
|
|
73
|
+
it is 25 MiB or smaller (Cloudflare's limit for one static file), and the Worker answers byte
|
|
74
|
+
ranges for it too. This is the default, and it needs nothing but a deploy.
|
|
75
|
+
3. `url`: an absolute `https://` address somewhere else.
|
|
76
|
+
|
|
77
|
+
A file with none of these is left out and `media list` says so. Media never goes into git: the
|
|
78
|
+
studio's `.gitignore` keeps `*.wav`, `*.mp3`, `*.flac`, `*.mp4`, `*.mov` and `*.webm` under
|
|
79
|
+
`music/` and `videos/` out of the repository, so without storage the site is deployed from the
|
|
80
|
+
computer that has the files. Keep deliveries under 25 MiB (the video skill's cuts do) or add
|
|
81
|
+
storage for bigger ones.
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
# Homie netplay contract, v1 (revision
|
|
2
|
-
|
|
3
|
-
Status: **v1, revision
|
|
4
|
-
2 added is either an optional field
|
|
5
|
-
|
|
1
|
+
# Homie netplay contract, v1 (revision 3)
|
|
2
|
+
|
|
3
|
+
Status: **v1, revision 3** (2026-09-30, `@homie-rocks/studio` 0.6.0). The wire version is
|
|
4
|
+
`v: 1`. Everything revision 2 and 3 added is either an optional field, a new message type,
|
|
5
|
+
or a change of pace inside the old caps, and both sides ignore types they do not know. A
|
|
6
|
+
change to the contract bumps `v` and keeps v1 working.
|
|
7
|
+
|
|
8
|
+
**What revision 3 added** (nothing on the wire changed; every v1 game plays on it):
|
|
9
|
+
- **Rooms of up to 32 seats**, sized by the game's netplay manifest (section 3).
|
|
10
|
+
- **One address may hold every seat plus four sockets** (section 6), so a party on one
|
|
11
|
+
Wi-Fi fills a 32-seat room with a TV beside it.
|
|
12
|
+
- **The checkpoint, round and roster caps double for 17–32 seats** (section 6): a
|
|
13
|
+
32-seat courier game's checkpoint measured 56 KB against the 64 KB cap.
|
|
14
|
+
- **Idle input keepalive** in the helper: an unchanged input frame goes out at most four
|
|
15
|
+
times a second (section 11).
|
|
16
|
+
- Measured cost and free-plan headroom of a 32-seat room (section 11).
|
|
6
17
|
|
|
7
18
|
**What revision 2 added:**
|
|
8
19
|
- Interpolation measures transit time.
|
|
@@ -95,8 +106,12 @@ code.
|
|
|
95
106
|
| `colour` | `seat % 12` | An index into a 12-colour palette. |
|
|
96
107
|
|
|
97
108
|
- **Every visitor gets a seat at once**, phone or desktop. There is no gathering.
|
|
98
|
-
- **Room size.** `maxPlayers` comes from the
|
|
99
|
-
|
|
109
|
+
- **Room size.** `maxPlayers` comes from the game's netplay manifest: game.json's
|
|
110
|
+
`netplay.maxPlayers`, or a `netplay.json` beside game.json or in the game's build
|
|
111
|
+
(`maxPlayers` or `players.max`), else game.json's `players.max`, else 8. **A room holds
|
|
112
|
+
at most 32** (`SEAT_MAX`). `homie-studio build` writes the number into the catalogue and
|
|
113
|
+
the site's Table takes it from there; the first visitor of an empty room may lower it
|
|
114
|
+
(`hello.max`), never raise it.
|
|
100
115
|
- **A dropped seat is held 60 s** for its token, which covers a reload or a network blip.
|
|
101
116
|
**A visitor who is present beats one who left:** when no seat is free, the relay
|
|
102
117
|
reclaims the seat that has been absent longest. Visitors who leave can never lock a
|
|
@@ -259,13 +274,15 @@ so `ev.origin` is `"null"`.
|
|
|
259
274
|
| `in` | < 256 B | 2 KB | 20 Hz, plus an immediate flush (≤ 60 Hz) on a press edge | 60 |
|
|
260
275
|
| `ev` | < 1 KB | 4 KB (512 B from a screen) | as needed | host 30, replica 10, screen 2 |
|
|
261
276
|
| `state` | < 2 KB per key | 8 KB per key; **64 keys and 64 KB per room** → `state-full` | only on change | 64 |
|
|
262
|
-
| `ckpt` | < 32 KB | 64 KB | 1 Hz | 4 |
|
|
263
|
-
| `round` / `roster` | < 2 KB / < 1 KB | 8 KB / 4 KB | on change | 4 / 8 |
|
|
277
|
+
| `ckpt` | < 32 KB | 64 KB (128 KB for 17–32 seats) | 1 Hz | 4 |
|
|
278
|
+
| `round` / `roster` | < 2 KB / < 1 KB | 8 KB / 4 KB (16 / 8 KB for 17–32 seats) | on change | 4 / 8 |
|
|
264
279
|
| `ping` | — | 256 B | every 2 s | 8 |
|
|
265
280
|
|
|
266
281
|
**Sockets.**
|
|
267
|
-
- At most `maxPlayers + 16` per room, and
|
|
268
|
-
|
|
282
|
+
- At most `maxPlayers + 16` per room, and per client address per room **12, or every seat
|
|
283
|
+
plus four when that is more** (`perAddress`: 12 for 8 seats, 20 for 16, 36 for 32). The
|
|
284
|
+
Worker passes `CF-Connecting-IP` as `conn.ip`. Measured before revision 3: 32 visitors on
|
|
285
|
+
one address got 12 seats and 20 `too-many` refusals.
|
|
269
286
|
- A client dropping more than 100 messages over the caps within 5 s is closed (`flood`).
|
|
270
287
|
|
|
271
288
|
**Keep it small.**
|
|
@@ -494,6 +511,31 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
|
|
|
494
511
|
writes/h ≈ $0.07/h, 9× everything else), or rewriting a whole log per event.
|
|
495
512
|
- Re-check Cloudflare's published prices before quoting.
|
|
496
513
|
|
|
514
|
+
**A 32-seat room (measured 2026-09-30, `@homie-rocks/studio` 0.6.0).** Every incoming
|
|
515
|
+
socket message is a Durable Object request (billed 20:1; outgoing messages are free), and
|
|
516
|
+
in a full room the replicas' inputs are nearly all of them:
|
|
517
|
+
|
|
518
|
+
| 32 seats, one host | Incoming msgs/s at the relay | Requests/hour (20:1) | Free plan (100,000 a day) |
|
|
519
|
+
|---|---|---|---|
|
|
520
|
+
| every replica moving, input 20 Hz | ≈ 630 | ≈ 114 k | ≈ 53 room-minutes a day |
|
|
521
|
+
| a game's own vendored helper, input 30 Hz | ≈ 860 | ≈ 155 k | ≈ 39 room-minutes a day |
|
|
522
|
+
| half the replicas standing still, 0.6.0 helper | ≈ 415 | ≈ 75 k | ≈ 80 room-minutes a day |
|
|
523
|
+
|
|
524
|
+
- Put another way, the free plan carries roughly **28 player-hours a day** at 20 Hz input
|
|
525
|
+
(each seated player costs about one request a second). A studio that plays more than
|
|
526
|
+
that a day needs Workers Paid ($5 a month), where a full 32-seat room-hour costs about
|
|
527
|
+
$0.02 in requests plus $0.006 in duration.
|
|
528
|
+
- **Idle keepalive (helper, 0.6.0):** an input frame identical to the last one sent (same
|
|
529
|
+
avatar or intent, same held keys, no press, same reset epoch) is re-sent at most every
|
|
530
|
+
250 ms. The host already holds that frame, so nothing it reads changes. A player standing
|
|
531
|
+
still costs 4 messages a second instead of 20. A game that vendors its own copy of the
|
|
532
|
+
helper gets this by updating that copy.
|
|
533
|
+
- Keep `inputHz` at 20 unless the game needs more: 30 Hz costs 1.5× the requests.
|
|
534
|
+
- Snapshots of a real 32-seat courier game (32 bodies plus the control table) were 1.1–1.7 KB at
|
|
535
|
+
20 Hz: about 32 KB/s down per replica, 2 KB/s up. No snapshot delta or compression was
|
|
536
|
+
needed; the 2 KB target and 16 KB cap hold at 32 seats. Duration (13,000 GB-s a day free)
|
|
537
|
+
covers one busy room all day.
|
|
538
|
+
|
|
497
539
|
## 12. Trust and safety
|
|
498
540
|
|
|
499
541
|
- **The host is a visitor's browser.** A modified host could fake scores. That is
|
|
@@ -569,7 +611,8 @@ npx --no-install homie-studio check my-game --url http://127.0.0.1:8787
|
|
|
569
611
|
candidate for v2, not in v1.
|
|
570
612
|
- **No lag compensation for hits.** A game can rewind a victim by the attacker's
|
|
571
613
|
`rtt/2 + delay` itself.
|
|
572
|
-
- **No snapshot deltas.** They are not needed at the measured sizes (Gem Rush: 364–427 B
|
|
614
|
+
- **No snapshot deltas.** They are not needed at the measured sizes (Gem Rush: 364–427 B;
|
|
615
|
+
a 32-body courier game's snapshot: 1.1–1.7 KB).
|
|
573
616
|
- **A reload shows a bot in the player's body for about 1–2 s**, and then the player
|
|
574
617
|
takes the same body back.
|
|
575
618
|
- **The host is a visitor's browser** (section 12).
|