@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/cloudflare.mjs
CHANGED
|
@@ -5,19 +5,25 @@
|
|
|
5
5
|
* The person's only step is Wrangler's sign-in (`npx wrangler login`, one
|
|
6
6
|
* approval in their browser); Wrangler keeps that login, this never sees a key.
|
|
7
7
|
*
|
|
8
|
-
* It creates what
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* NO CREDIT CARD. It creates only what Cloudflare's free Workers plan gives a
|
|
9
|
+
* brand-new account with no payment method, by the names in studio.json:
|
|
10
|
+
* the Worker (pages + rooms), a D1 database, and the Table/Lobby Durable
|
|
11
|
+
* Objects (SQLite-backed, declared by the Worker).
|
|
12
|
+
* It never creates or binds R2: Cloudflare asks for a payment method before R2
|
|
13
|
+
* works, even inside its free tier, so storage for large media is its own later
|
|
14
|
+
* step (`homie-studio storage add`) that the person agrees to.
|
|
15
|
+
*
|
|
16
|
+
* It REFUSES to touch a Worker, database or bucket of that name that this
|
|
12
17
|
* studio did not create (studio.json `cloudflare.created` is the record), so it
|
|
13
|
-
* can never overwrite someone's existing site.
|
|
18
|
+
* can never overwrite someone's existing site. `deploy --plan` says all of this
|
|
19
|
+
* before anything happens and calls nothing.
|
|
14
20
|
*/
|
|
15
21
|
import { spawnSync } from 'node:child_process';
|
|
16
22
|
import { existsSync, writeFileSync } from 'node:fs';
|
|
17
23
|
import { join } from 'node:path';
|
|
18
24
|
import { build } from './build.mjs';
|
|
19
|
-
import { wranglerConfig } from './scaffold.mjs';
|
|
20
|
-
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';
|
|
21
27
|
|
|
22
28
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
23
29
|
|
|
@@ -26,7 +32,8 @@ export function wranglerBin(root) {
|
|
|
26
32
|
return existsSync(local) ? local : null;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
/** Wrangler from the studio's own node_modules, run in site/ with the given environment. */
|
|
36
|
+
export function runner(root, env) {
|
|
30
37
|
const bin = wranglerBin(root);
|
|
31
38
|
if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
|
|
32
39
|
return (args, { cwd = join(root, 'site'), input } = {}) => {
|
|
@@ -53,19 +60,76 @@ function parseJson(text) {
|
|
|
53
60
|
try { return JSON.parse(text.slice(start)); } catch { return null; }
|
|
54
61
|
}
|
|
55
62
|
|
|
63
|
+
/*
|
|
64
|
+
* WHAT A NEW ACCOUNT TRIPS ON, said as the next step instead of a Wrangler stack.
|
|
65
|
+
* Measured names: 10034 is Cloudflare's "verify your email address" (a new
|
|
66
|
+
* account cannot deploy a Worker until it does); a missing workers.dev
|
|
67
|
+
* subdomain is registered by Wrangler itself when an agent runs it (Claude Code
|
|
68
|
+
* and Codex are detected), and otherwise needs one visit to the onboarding page.
|
|
69
|
+
*/
|
|
70
|
+
export function explainCloudflare(out, accountId = null) {
|
|
71
|
+
const text = String(out ?? '');
|
|
72
|
+
const dash = accountId ? `https://dash.cloudflare.com/${accountId}` : 'https://dash.cloudflare.com';
|
|
73
|
+
if (/\b10034\b|verify your email/i.test(text)) {
|
|
74
|
+
return { needs: 'cloudflare-verify-email', why: `Cloudflare needs this account's email address verified before it runs a Worker. Ask the person to open the verification email from Cloudflare (or ${dash}/profile and "Send verification email"), click it, then run \`npm run deploy\` again. No payment method is needed.` };
|
|
75
|
+
}
|
|
76
|
+
if (/register a workers\.dev subdomain|workers\/onboarding/i.test(text)) {
|
|
77
|
+
return { needs: 'workers-dev-subdomain', why: `This Cloudflare account has no workers.dev address yet. Ask the person to open ${dash}/workers/onboarding once and pick one (it is free), then run \`npm run deploy\` again.` };
|
|
78
|
+
}
|
|
79
|
+
if (/maximum number of (D1 )?databases|database limit|too many databases/i.test(text)) {
|
|
80
|
+
return { needs: 'd1-limit', why: 'This Cloudflare account has used all the D1 databases its plan allows (10 on the free plan). Nothing was deployed. Ask the person which unused database of theirs to remove, or use another account; never delete one yourself.' };
|
|
81
|
+
}
|
|
82
|
+
if (/\b1027\b|exceeded .*daily request limit/i.test(text)) {
|
|
83
|
+
return { needs: 'free-plan-daily-limit', why: 'This account used its free plan\'s 100,000 Worker requests for today (it resets at 00:00 UTC). Nothing is broken; try again after the reset.' };
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What `deploy` will create and what it costs, from studio.json alone. It calls nothing. */
|
|
89
|
+
export function deployPlan(root) {
|
|
90
|
+
const studio = readStudio(root);
|
|
91
|
+
const cf = { created: [], ...studio.cloudflare };
|
|
92
|
+
const created = new Set(cf.created ?? []);
|
|
93
|
+
const storage = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
|
|
94
|
+
const mark = (key) => (created.has(key) ? 'exists (this studio made it)' : 'will be created');
|
|
95
|
+
return {
|
|
96
|
+
ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
|
|
97
|
+
cloudflare: [
|
|
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' },
|
|
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)' },
|
|
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)' },
|
|
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)' },
|
|
102
|
+
storage
|
|
103
|
+
? { kind: 'R2 bucket', name: storage, what: 'large media for /media/<key> (added with storage add)', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
|
|
104
|
+
: { kind: 'R2 bucket', name: null, what: 'none: a new studio needs no storage. `homie-studio storage add` adds it later, for large media only', state: 'not created', plan: null },
|
|
105
|
+
],
|
|
106
|
+
cost: storage
|
|
107
|
+
? 'Free on Cloudflare\'s Workers Free plan. R2 storage is free up to 10 GB-month; beyond that Cloudflare bills the account directly.'
|
|
108
|
+
: 'Free: everything above is on Cloudflare\'s Workers Free plan, which needs no payment method. Its daily limits (100,000 Worker requests; D1 5 million rows read and 100,000 written) reset at 00:00 UTC; past them requests fail until the reset, nothing is charged.',
|
|
109
|
+
login: 'One approval: `npx wrangler login` opens Cloudflare in the person\'s browser (a free account works; a new one verifies its email address first).',
|
|
110
|
+
address: `https://${cf.worker}.<the account's workers.dev subdomain>.workers.dev`,
|
|
111
|
+
directory: {
|
|
112
|
+
site: studio.homie?.directory || 'https://homie.rocks',
|
|
113
|
+
stores: 'the site\'s address and a claim token, the studio\'s name and slug, the @homie-rocks/studio version, and each game\'s id, name, blurb and Play/page links. Never code, media, keys or accounts.',
|
|
114
|
+
},
|
|
115
|
+
never: 'It never touches a Worker, database or bucket this studio did not create, and it never adds a payment method or buys anything.',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
56
119
|
export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
57
120
|
const studio = readStudio(root);
|
|
58
|
-
const cf = { r2:
|
|
121
|
+
const cf = { r2: null, created: [], ...studio.cloudflare };
|
|
59
122
|
const created = new Set(cf.created ?? []);
|
|
60
123
|
const who = whoami(root);
|
|
61
124
|
if (!who) {
|
|
62
|
-
return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: 'Wrangler is not signed in to Cloudflare. Run `npx wrangler login` in the studio folder: it opens Cloudflare in the browser and the person approves once. Then run `npm run deploy` again.' };
|
|
125
|
+
return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: 'Wrangler is not signed in to Cloudflare. Run `npx wrangler login` in the studio folder: it opens Cloudflare in the browser and the person approves once (a free account works, no payment method). Then run `npm run deploy` again.' };
|
|
63
126
|
}
|
|
64
127
|
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
|
|
65
128
|
if (!accountId) {
|
|
66
129
|
return { ok: false, command: 'deploy', needs: 'cloudflare-account', why: `This Cloudflare login can reach ${who.accounts.length} accounts; ask the person which one this studio uses and put its id in studio.json (cloudflare.accountId).`, accounts: who.accounts };
|
|
67
130
|
}
|
|
68
131
|
const w = runner(root, { CLOUDFLARE_ACCOUNT_ID: accountId });
|
|
132
|
+
const refuse = (why, out) => ({ ok: false, command: 'deploy', ...(explainCloudflare(out, accountId) ?? { why }) });
|
|
69
133
|
// Record every resource the moment it exists, so a deploy cut short (an expired login, a network drop) resumes
|
|
70
134
|
// instead of refusing its own database as "someone else's" next time.
|
|
71
135
|
const remember = (key) => {
|
|
@@ -75,9 +139,22 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
75
139
|
const steps = [];
|
|
76
140
|
const step = (what, extra = {}) => { steps.push({ what, ...extra }); log(what); };
|
|
77
141
|
|
|
142
|
+
// The first deploy says what it is about to create, and what it costs, before it creates anything: the person
|
|
143
|
+
// reads it in the transcript whether or not the AI repeated it.
|
|
144
|
+
const announced = [];
|
|
145
|
+
if (!created.has(`worker:${cf.worker}`)) {
|
|
146
|
+
const account = who.accounts.find((a) => a.id === accountId)?.name ?? 'the signed-in account';
|
|
147
|
+
announced.push(
|
|
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).`,
|
|
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).',
|
|
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.`,
|
|
151
|
+
);
|
|
152
|
+
for (const line of announced) log(line);
|
|
153
|
+
}
|
|
154
|
+
|
|
78
155
|
const b = await build(root, { log });
|
|
79
|
-
if (!b.catalogue.length) return { ok: false, command: 'deploy', why: '
|
|
80
|
-
step(`built ${b.catalogue.length} game(s)`);
|
|
156
|
+
if (!b.catalogue.length && !b.songs.length && !b.videos.length) return { ok: false, command: 'deploy', why: 'nothing to put online yet: make a game (`npx --no-install homie-studio game new <id> --from gem-rush`) or publish a song or video in music/ or videos/ (media/MEDIA.md)' };
|
|
157
|
+
step(`built ${b.catalogue.length} game(s), ${b.songs.length} song(s), ${b.videos.length} video(s)`);
|
|
81
158
|
|
|
82
159
|
// The Worker: never one this studio did not make.
|
|
83
160
|
if (!created.has(`worker:${cf.worker}`)) {
|
|
@@ -85,7 +162,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
85
162
|
if (exists.code === 0) {
|
|
86
163
|
return { ok: false, command: 'deploy', why: `A Worker named "${cf.worker}" already exists on this Cloudflare account and this studio did not create it, so nothing was deployed. Pick another name in studio.json (cloudflare.worker) and site/wrangler.jsonc (name).` };
|
|
87
164
|
}
|
|
88
|
-
if (!/10007|does not exist/i.test(exists.out)) return
|
|
165
|
+
if (!/10007|does not exist/i.test(exists.out)) return refuse(`could not check the Worker name: ${exists.out.trim().split('\n').slice(-3).join(' ')}`, exists.out);
|
|
89
166
|
}
|
|
90
167
|
|
|
91
168
|
// D1.
|
|
@@ -98,39 +175,44 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
98
175
|
if (!db) {
|
|
99
176
|
const made = w(['d1', 'create', cf.d1]);
|
|
100
177
|
const id = /"?database_id"?\s*[:=]\s*"([0-9a-f-]{36})"/.exec(made.out)?.[1];
|
|
101
|
-
if (made.code !== 0 || !id) return
|
|
178
|
+
if (made.code !== 0 || !id) return refuse(`could not create the D1 database "${cf.d1}": ${made.out.trim().split('\n').slice(-4).join(' ')}`, made.out);
|
|
102
179
|
db = { name: cf.d1, uuid: id };
|
|
103
180
|
remember(`d1:${cf.d1}`);
|
|
104
181
|
step(`created D1 ${cf.d1}`);
|
|
105
182
|
}
|
|
106
183
|
|
|
107
|
-
// R2
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const names = [...buckets.out.matchAll(/^name:\s+(\S+)/gm)].map((m) => m[1]);
|
|
112
|
-
if (names.includes(r2)) {
|
|
113
|
-
return { ok: false, command: 'deploy', why: `An R2 bucket named "${r2}" already exists on this account and this studio did not create it; nothing was deployed. Pick another name in studio.json (cloudflare.r2).` };
|
|
114
|
-
}
|
|
115
|
-
const made = w(['r2', 'bucket', 'create', r2]);
|
|
116
|
-
if (made.code === 0) { remember(`r2:${r2}`); step(`created R2 ${r2}`); }
|
|
117
|
-
else if (/enable R2|R2 is not enabled|10042|purchase|subscription/i.test(made.out)) { step('R2 is not enabled on this account: the site deploys without media storage for now'); r2 = null; }
|
|
118
|
-
else return { ok: false, command: 'deploy', why: `could not create the R2 bucket "${r2}": ${made.out.trim().split('\n').slice(-4).join(' ')}` };
|
|
119
|
-
}
|
|
184
|
+
// Storage (R2) is bound only when `storage add` made the bucket. Deploy itself never creates or lists R2, so a
|
|
185
|
+
// free account with no payment method deploys the whole studio.
|
|
186
|
+
const r2 = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
|
|
187
|
+
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
120
188
|
|
|
121
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)`);
|
|
122
192
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
123
|
-
if (migrate.code !== 0) return
|
|
193
|
+
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
124
194
|
step('D1 migrations applied');
|
|
125
195
|
|
|
126
196
|
const started = Date.now();
|
|
127
197
|
const dep = w(['deploy']);
|
|
128
|
-
if (dep.code !== 0) return
|
|
198
|
+
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
|
|
129
199
|
remember(`worker:${cf.worker}`);
|
|
130
|
-
|
|
131
|
-
|
|
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 } : {}) });
|
|
132
213
|
|
|
133
|
-
// 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.
|
|
134
216
|
const directory = homie || studio.homie?.directory || 'https://homie.rocks';
|
|
135
217
|
let claim = null;
|
|
136
218
|
if (url) {
|
|
@@ -145,11 +227,64 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
145
227
|
}
|
|
146
228
|
}
|
|
147
229
|
|
|
148
|
-
|
|
149
|
-
|
|
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 });
|
|
150
234
|
return {
|
|
151
|
-
ok: true, command: 'deploy', url, worker: cf.worker, d1: cf.d1, r2, account: accountId, steps,
|
|
235
|
+
ok: true, command: 'deploy', url, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
|
|
152
236
|
games: b.catalogue.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })),
|
|
237
|
+
songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
|
|
238
|
+
videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
|
|
153
239
|
claim: Boolean(claim), directory,
|
|
154
240
|
};
|
|
155
241
|
}
|
|
242
|
+
|
|
243
|
+
/*
|
|
244
|
+
* `homie-studio storage add` — the studio's own storage for large media (songs,
|
|
245
|
+
* videos, big art), as an R2 bucket on its account, bound as MEDIA and served at
|
|
246
|
+
* /media/<key>. It is separate from deploy on purpose: Cloudflare asks for a
|
|
247
|
+
* payment method on the account before R2 works, even inside R2's free tier
|
|
248
|
+
* (10 GB-month), so this is a step the person agrees to, and a studio that only
|
|
249
|
+
* makes games never needs it. It changes nothing on Cloudflare but the one bucket.
|
|
250
|
+
*/
|
|
251
|
+
export async function storageAdd(root, { log = () => {} } = {}) {
|
|
252
|
+
const studio = readStudio(root);
|
|
253
|
+
const cf = { created: [], ...studio.cloudflare };
|
|
254
|
+
const created = new Set(cf.created ?? []);
|
|
255
|
+
const bucket = cf.r2 || `${studio.slug}-media`;
|
|
256
|
+
if (created.has(`r2:${bucket}`)) {
|
|
257
|
+
return { ok: true, command: 'storage add', bucket, already: true, next: ['npm run deploy (binds it as MEDIA if the live site does not have it yet)'] };
|
|
258
|
+
}
|
|
259
|
+
const who = whoami(root);
|
|
260
|
+
if (!who) return { ok: false, command: 'storage add', needs: 'cloudflare-login', why: 'Wrangler is not signed in to Cloudflare. Run `npx wrangler login` first (one approval in the browser).' };
|
|
261
|
+
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
|
|
262
|
+
if (!accountId) return { ok: false, command: 'storage add', needs: 'cloudflare-account', why: 'This login reaches several Cloudflare accounts; put the studio\'s in studio.json (cloudflare.accountId) first.', accounts: who.accounts };
|
|
263
|
+
const w = runner(root, { CLOUDFLARE_ACCOUNT_ID: accountId });
|
|
264
|
+
const enable = {
|
|
265
|
+
ok: false, command: 'storage add', needs: 'r2-payment-method', bucket,
|
|
266
|
+
why: `R2 is not turned on for this Cloudflare account. Cloudflare asks for a payment method before R2 works, even inside its free tier (10 GB-month of storage, 1 million writes and 10 million reads a month free). Only the person can decide that: if they want storage, they open https://dash.cloudflare.com/${accountId}/r2/overview, add R2 there, and you run \`npx --no-install homie-studio storage add\` again. Nothing was created; the studio keeps working without storage.`,
|
|
267
|
+
};
|
|
268
|
+
const buckets = w(['r2', 'bucket', 'list']);
|
|
269
|
+
if (buckets.code !== 0) {
|
|
270
|
+
if (/enable R2|R2 is not enabled|10042|purchase|subscription|payment/i.test(buckets.out)) return enable;
|
|
271
|
+
return { ok: false, command: 'storage add', ...(explainCloudflare(buckets.out, accountId) ?? { why: `could not list R2 buckets: ${buckets.out.trim().split('\n').slice(-3).join(' ')}` }) };
|
|
272
|
+
}
|
|
273
|
+
const names = [...buckets.out.matchAll(/^name:\s+(\S+)/gm)].map((m) => m[1]);
|
|
274
|
+
if (names.includes(bucket)) {
|
|
275
|
+
return { ok: false, command: 'storage add', why: `An R2 bucket named "${bucket}" already exists on this account and this studio did not create it; nothing was created. Put another name in studio.json (cloudflare.r2) and run storage add again.` };
|
|
276
|
+
}
|
|
277
|
+
const made = w(['r2', 'bucket', 'create', bucket]);
|
|
278
|
+
if (made.code !== 0) {
|
|
279
|
+
if (/enable R2|R2 is not enabled|10042|purchase|subscription|payment/i.test(made.out)) return enable;
|
|
280
|
+
return { ok: false, command: 'storage add', why: `could not create the R2 bucket "${bucket}": ${made.out.trim().split('\n').slice(-4).join(' ')}` };
|
|
281
|
+
}
|
|
282
|
+
created.add(`r2:${bucket}`);
|
|
283
|
+
const next = { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, accountId, r2: bucket, created: [...created].sort() } };
|
|
284
|
+
writeStudio(root, next);
|
|
285
|
+
if (next.cloudflare.d1Id) {
|
|
286
|
+
writeFileSync(join(root, 'site', 'wrangler.jsonc'), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket }));
|
|
287
|
+
}
|
|
288
|
+
log(`created R2 ${bucket}`);
|
|
289
|
+
return { ok: true, command: 'storage add', bucket, account: accountId, next: ['npm run deploy (binds the bucket as MEDIA; the site serves it at /media/<key>)', 'npx --no-install homie-studio media put <file>'] };
|
|
290
|
+
}
|
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/media.mjs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A studio's music/ and videos/ manifests (media/MEDIA.md): what the site shows,
|
|
3
|
+
* and where each file's bytes come from (R2 key, a file in the repository copied
|
|
4
|
+
* into the site, or an absolute https address).
|
|
5
|
+
*/
|
|
6
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, normalize, relative, resolve, sep } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
10
|
+
export const KINDS = {
|
|
11
|
+
music: ['song', 'score', 'loop', 'stem', 'sfx'],
|
|
12
|
+
videos: ['trailer', 'music-video', 'cutscene', 'clip'],
|
|
13
|
+
};
|
|
14
|
+
/** Cloudflare Workers static assets: one file may be at most 25 MiB. */
|
|
15
|
+
export const MAX_ASSET_BYTES = 25 * 1024 * 1024;
|
|
16
|
+
const TYPES = {
|
|
17
|
+
'.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.opus': 'audio/ogg', '.wav': 'audio/wav', '.flac': 'audio/flac',
|
|
18
|
+
'.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
|
|
19
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.vtt': 'text/vtt', '.json': 'application/json',
|
|
20
|
+
};
|
|
21
|
+
export const typeOf = (path) => TYPES[(/\.[a-z0-9]+$/i.exec(String(path)) ?? [''])[0].toLowerCase()] ?? 'application/octet-stream';
|
|
22
|
+
|
|
23
|
+
export function manifestPath(root, kind) { return join(root, kind, 'manifest.json'); }
|
|
24
|
+
|
|
25
|
+
export function readManifest(root, kind) {
|
|
26
|
+
const file = manifestPath(root, kind);
|
|
27
|
+
if (!existsSync(file)) return { v: 1, items: [] };
|
|
28
|
+
const m = JSON.parse(readFileSync(file, 'utf8'));
|
|
29
|
+
return { v: 1, ...m, items: Array.isArray(m.items) ? m.items : [] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function writeManifest(root, kind, manifest) {
|
|
33
|
+
mkdirSync(join(root, kind), { recursive: true });
|
|
34
|
+
writeFileSync(manifestPath(root, kind), `${JSON.stringify({ v: 1, ...manifest }, null, 2)}\n`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A path inside the studio, or null (no absolute paths, no `..` out of the repository). */
|
|
38
|
+
function inside(root, rel) {
|
|
39
|
+
if (typeof rel !== 'string' || !rel || rel.startsWith('/') || /^[a-z]+:/i.test(rel)) return null;
|
|
40
|
+
const abs = resolve(root, normalize(rel));
|
|
41
|
+
return abs.startsWith(`${resolve(root)}${sep}`) ? abs : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every entry of one manifest with its files resolved, and why anything is left out.
|
|
46
|
+
* `r2`: the studio has its own bucket (so a `key` is served at /media/<key>).
|
|
47
|
+
*/
|
|
48
|
+
export function resolveMedia(root, kind, { r2 = false } = {}) {
|
|
49
|
+
const manifest = readManifest(root, kind);
|
|
50
|
+
const entries = [];
|
|
51
|
+
const skipped = [];
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
for (const item of manifest.items) {
|
|
54
|
+
if (!item || typeof item !== 'object') continue;
|
|
55
|
+
const label = item.slug ?? item.key ?? item.file ?? '(unnamed)';
|
|
56
|
+
if (!SLUG.test(String(item.slug ?? '')) || !item.title) { if (item.published !== false) skipped.push({ kind, item: label, why: 'no slug and title: kept in the manifest, no page' }); continue; }
|
|
57
|
+
if (seen.has(item.slug)) { skipped.push({ kind, item: label, why: 'a second entry with the same slug' }); continue; }
|
|
58
|
+
seen.add(item.slug);
|
|
59
|
+
if (item.published !== true) { skipped.push({ kind, item: label, why: 'not published yet ("published": true shows it)' }); continue; }
|
|
60
|
+
const files = [];
|
|
61
|
+
for (const f of Array.isArray(item.files) ? item.files : []) {
|
|
62
|
+
if (!f || f.public === false) continue;
|
|
63
|
+
let src = null;
|
|
64
|
+
if (typeof f.key === 'string' && f.key && r2) src = { from: 'r2', url: `/media/${f.key.split('/').map(encodeURIComponent).join('/')}` };
|
|
65
|
+
if (!src && f.path) {
|
|
66
|
+
const abs = inside(root, f.path);
|
|
67
|
+
if (!abs) { skipped.push({ kind, item: item.slug, file: f.path, why: 'the path is outside the studio' }); continue; }
|
|
68
|
+
if (existsSync(abs) && statSync(abs).isFile()) {
|
|
69
|
+
const bytes = statSync(abs).size;
|
|
70
|
+
if (bytes <= MAX_ASSET_BYTES) src = { from: 'site', url: `/${relative(root, abs).split(sep).map(encodeURIComponent).join('/')}`, abs, rel: relative(root, abs), bytes };
|
|
71
|
+
else skipped.push({ kind, item: item.slug, file: f.path, why: `${Math.round(bytes / 1048576)} MiB is over the 25 MiB the site serves itself: make it smaller, or give the studio storage (homie-studio storage add; Cloudflare asks for a payment method first, so ask the person) and upload it (homie-studio media put ${f.path})` });
|
|
72
|
+
} else if (f.key && !r2) skipped.push({ kind, item: item.slug, file: f.path, why: `the file is not on this computer and its storage key "${f.key}" needs the studio's storage (homie-studio storage add)` });
|
|
73
|
+
else if (!f.key && !f.url) skipped.push({ kind, item: item.slug, file: f.path, why: 'the file is not on this computer and has no R2 key or url' });
|
|
74
|
+
}
|
|
75
|
+
if (!src && typeof f.url === 'string' && /^https:\/\//.test(f.url)) src = { from: 'url', url: f.url };
|
|
76
|
+
if (!src) continue;
|
|
77
|
+
files.push({ role: String(f.role ?? 'file'), name: f.name ?? null, type: f.type ?? typeOf(f.path ?? f.key ?? f.url), bytes: f.bytes ?? src.bytes ?? null, bars: f.bars ?? null, url: src.url, from: src.from, ...(src.abs ? { abs: src.abs, rel: src.rel } : {}) });
|
|
78
|
+
}
|
|
79
|
+
const main = kind === 'music' ? 'audio' : 'video';
|
|
80
|
+
if (!files.some((f) => f.role === main)) { skipped.push({ kind, item: item.slug, why: `no playable "${main}" file reachable (see the file lines above)` }); continue; }
|
|
81
|
+
entries.push({
|
|
82
|
+
slug: item.slug, kind: KINDS[kind].includes(item.kind) ? item.kind : KINDS[kind][0], title: String(item.title).slice(0, 120), blurb: String(item.blurb ?? '').slice(0, 600),
|
|
83
|
+
duration: Number.isFinite(item.duration) ? item.duration : null, bpm: Number.isFinite(item.bpm) ? item.bpm : null, key: item.key ?? null,
|
|
84
|
+
lyrics: typeof item.lyrics === 'string' ? item.lyrics.slice(0, 8000) : null, credits: typeof item.credits === 'string' ? item.credits.slice(0, 600) : null,
|
|
85
|
+
rights: item.rights && typeof item.rights === 'object' ? item.rights : null, for: item.for && typeof item.for === 'object' ? item.for : null,
|
|
86
|
+
honesty: typeof item.honesty === 'string' ? item.honesty.slice(0, 600) : null, made: item.made?.at ? { at: item.made.at, provider: item.made.provider ?? null } : null,
|
|
87
|
+
files,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return { entries, skipped };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Copy the site-served files into site/dist and return the catalogue rows (no local paths in them). */
|
|
94
|
+
export function buildMedia(root, dist, { r2 = false, log = () => {} } = {}) {
|
|
95
|
+
const out = {};
|
|
96
|
+
const skipped = [];
|
|
97
|
+
for (const kind of ['music', 'videos']) {
|
|
98
|
+
const r = resolveMedia(root, kind, { r2 });
|
|
99
|
+
skipped.push(...r.skipped);
|
|
100
|
+
out[kind] = r.entries.map((e) => ({
|
|
101
|
+
...e,
|
|
102
|
+
files: e.files.map(({ abs, rel, from, ...f }) => {
|
|
103
|
+
if (from === 'site') { const dest = join(dist, rel); mkdirSync(dirname(dest), { recursive: true }); cpSync(abs, dest); }
|
|
104
|
+
return f;
|
|
105
|
+
}),
|
|
106
|
+
}));
|
|
107
|
+
if (out[kind].length) log(`${kind}: ${out[kind].map((e) => e.slug).join(', ')}`);
|
|
108
|
+
}
|
|
109
|
+
for (const s of skipped) log(`${s.kind}/${s.item}${s.file ? ` ${s.file}` : ''}: ${s.why}`);
|
|
110
|
+
return { songs: out.music, videos: out.videos, skipped };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `homie-studio media put`: record an uploaded file's R2 key on the entry that names it (or a bare entry). */
|
|
114
|
+
export function recordUpload(root, rel, key, bytes) {
|
|
115
|
+
const kind = rel.startsWith('videos/') ? 'videos' : 'music';
|
|
116
|
+
const m = readManifest(root, kind);
|
|
117
|
+
let hit = false;
|
|
118
|
+
for (const item of m.items) {
|
|
119
|
+
for (const f of Array.isArray(item?.files) ? item.files : []) {
|
|
120
|
+
if (f.path === rel) { f.key = key; f.bytes = bytes; hit = true; }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!hit) m.items = [...m.items.filter((i) => i.key !== key), { key, file: rel, bytes, url: `/media/${key.split('/').map(encodeURIComponent).join('/')}`, at: new Date().toISOString() }];
|
|
124
|
+
writeManifest(root, kind, m);
|
|
125
|
+
return { kind, manifest: relative(root, manifestPath(root, kind)), entry: hit };
|
|
126
|
+
}
|