@homie-rocks/studio 0.54.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.md +2 -0
- package/bin/homie-studio.mjs +1 -1
- package/docs/custom-routes.md +72 -0
- package/guides/app/SKILL.md +7 -0
- package/guides/playtest/scripts/playtest.mjs +7 -5
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +4 -1
- package/lib/check.mjs +8 -1
- package/lib/cloudflare.mjs +65 -23
- package/lib/dev.mjs +6 -8
- package/lib/local-database-start.mjs +6 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/port-check.mjs +3 -2
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +30 -10
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +87 -0
- package/lib/worker-build.mjs +16 -4
- package/lib/worker-config.mjs +10 -1
- package/netplay/NETPLAY.md +5 -1
- package/netplay/netplay.ts +24 -9
- package/package.json +1 -1
- package/rules/core.ts +9 -0
- package/rules/host.ts +40 -11
- package/rules/view.ts +1 -0
- package/tools/TOOLS.md +6 -1
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-streams.mjs +3 -3
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +31 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/custom-routes.mjs +16 -0
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +23 -11
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +106 -91
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/idle-chat.mjs +56 -0
- package/worker/index.mjs +56 -2785
- package/worker/lazy-objects.mjs +43 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby-peak.mjs +44 -0
- package/worker/lobby-shards.mjs +43 -0
- package/worker/lobby-store.mjs +35 -0
- package/worker/lobby-summary.mjs +28 -0
- package/worker/lobby.mjs +319 -0
- package/worker/lounge-store.mjs +2 -2
- package/worker/lounge.mjs +3 -3
- package/worker/match-router.mjs +74 -0
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-room.mjs +2 -1
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp-tools.mjs +4 -2
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +18 -21
- package/worker/operational-schema.mjs +16 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +9 -29
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/relay-channel.mjs +119 -0
- package/worker/request-db.mjs +36 -10
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +217 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room-sessions.mjs +65 -0
- package/worker/room.mjs +54 -740
- package/worker/rooms-cache.mjs +19 -0
- package/worker/route-auth.mjs +22 -0
- package/worker/routes.mjs +670 -0
- package/worker/runtime-support.mjs +447 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/service-run.mjs +1 -1
- package/worker/session-codec.mjs +24 -0
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop.mjs +27 -20
- package/worker/stats.mjs +9 -7
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1448 -0
- package/worker/tool-registry.mjs +10 -0
- package/worker/version.mjs +1 -1
- package/worker/watch-sockets.mjs +75 -0
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,72 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.56.0] - 2026-10-15
|
|
21
|
+
|
|
22
|
+
**Plugin 0.56.0**
|
|
23
|
+
|
|
24
|
+
Studios scale with hibernating rooms, add custom routes with built-in access checks, and deploy reliably with background queue handlers.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Studio Worker `useRoutes` hook for custom APIs, well-known files and callbacks, after Homie's matched routes and before Worker asset fallback, with response security headers.
|
|
29
|
+
- Public `isOwner(request, env)` and `hasRole(request, env, app, role)` authorization helpers using existing live session and app access checks.
|
|
30
|
+
- Secret capabilities may use `self` for the runtime studio origin, with origin and redirect restrictions retained.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- Renew relay sequence epochs whenever a Gate opens a link, probe the room after a missed round start, refill quiet games across all matching pools, and coalesce peak increases without writing unchanged room reports.
|
|
35
|
+
- Recover Gate relay transport failures with ordered backoff and bounded output queues; remove dead sessions so players can reconnect. Keep idle heartbeats at the edge through long breaks, exclude transport telemetry from session writes, and retain only QA markers from relay user agents. Match quiet public games in one pool, spread bursts through leased shard routing, and calculate daily peaks from concurrent shard totals.
|
|
36
|
+
- Close failed room restores, detect unanswered clock pings, preserve live Gate multiplexing, bound crowd session storage, and avoid unchanged idle writes. Restore watcher rate limits without replay, shard matchmaking, and paginate aggregated room summaries.
|
|
37
|
+
- Add an opt-in rules-host round-idle contract: finish both scoring-settlement ticks, persist the next-round deadline, and resume without simulating idle time.
|
|
38
|
+
- Store Lobby rooms as SQLite rows and deliver room summaries through Queues. Hibernate player and watch sockets, including Gates and server-rule rooms, between settled rounds. Retain logical sessions and moderation state, detach idle relay streams, and restore complete world headers without repeating admission.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- Custom studio Workers that wrap only `fetch` now inherit missing toolkit queue and scheduled handlers in the generated entry, so background queue consumers attach during deploy. Custom handlers remain intact.
|
|
43
|
+
- Deploy failures retain Wrangler's real error and context with secrets redacted, instead of showing only the last six lines.
|
|
44
|
+
- Custom route role checks enforce fresh app launch gates. Custom Worker asset routes preserve generated authorization paths; same-origin mutation protection is exported and documented with reserved `/api/custom/*` paths and app cookie scope.
|
|
45
|
+
- Custom `assets.run_worker_first` entries cannot exclude paths with `!`; the build refuses them so private app files always stay behind their access checks.
|
|
46
|
+
- Late link subscriptions no longer trigger rules-view reconnect recovery; throwing listeners still return their unsubscribe function.
|
|
47
|
+
- MCP publishing and its preflight use the connector's directory. Wrangler sign-in checks stop immediately when logged out and retry refresh collisions without blocking the event loop, including CLI status.
|
|
48
|
+
- Scaffold, local database and login regression tests use OS temporary directories and work from the package test command.
|
|
49
|
+
- New studios leave directory listing opt-in; existing explicit directory choices are preserved.
|
|
50
|
+
- Deploy and storage sign-in checks retry shared Wrangler OAuth refresh races with bounded backoff and report the original diagnostic, distinguishing authentication from temporary check failures.
|
|
51
|
+
- Room-link listeners immediately receive current state, so wall UIs mounted after welcome do not stay on “Connecting to Homie.”
|
|
52
|
+
- Local dev refuses to serve until migrations and function registration succeed, preserving first-notice delivery on a fresh database.
|
|
53
|
+
|
|
54
|
+
### Upgrade notes
|
|
55
|
+
|
|
56
|
+
- `net.on('link', listener)` now calls the listener synchronously on subscription with `why: 'subscribe'` and `prev === state`, then reports changes as before.
|
|
57
|
+
- Custom routes and authorization examples are in `packages/studio/docs/custom-routes.md` (the installed package uses `docs/custom-routes.md`).
|
|
58
|
+
|
|
59
|
+
## [0.55.0] - 2026-10-15
|
|
60
|
+
|
|
61
|
+
**Plugin 0.55.0**
|
|
62
|
+
|
|
63
|
+
Studios start faster and serve files straight from Cloudflare, background work and Stripe events go through retrying queues so nothing is lost, and the runtime follows Cloudflare's published practice.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- Deploy with D1 primary fallback when replication is unavailable; only install needed schedules, explain account cron limits, and preserve retired-source, HEAD and concept-page protections.
|
|
68
|
+
- Deliver mutations and verified Stripe webhooks through retryable Queues. Store PaymentIntent ids before follow-up, recover lost responses using the original idempotency key, and retain uncertain payments for review.
|
|
69
|
+
- Batch cold metadata, use causally consistent D1 sessions, bake catalogues, route public assets first, isolate subscription objects and load rendering modules at their entry points. Index MCP expiry and deliver catalogue notifications in background work.
|
|
70
|
+
- Split request routing, runtime helpers, Table, Lobby, room chat, controls and wire validation into modules without changing behavior.
|
|
71
|
+
- Skip unchanged analytics counters and watermarks, paginate rollups by UTC day, and show delayed counts in owner statistics.
|
|
72
|
+
- Set `stats.analytics: true` in studio.json to count page activity with Analytics Engine and hourly, replay-safe D1 rollups that preserve historical totals. Owner statistics explain ingestion lag and sampling.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- Weight Analytics Engine page counts by the sample interval, preserving historical totals and retry-safe corrections under sampling.
|
|
77
|
+
- Stop queued Stripe reconciliation database operations and provider calls after lease renewal fails or the lease expires, leaving the event pending for retry.
|
|
78
|
+
- Preserve live MCP OAuth and webhook dedupe rows during cleanup by using Unix seconds throughout MCP storage; reclaim idle guests occasionally on guest creation even without a studio cron.
|
|
79
|
+
- Recover verified Stripe intake from D1 after queue failures, renew reconciliation leases, preserve replay API versions, report stuck deliveries, and isolate scheduled maintenance failures. Resume partial queue setup with clear CI token permission guidance.
|
|
80
|
+
- Confine Chrome debugging pipes to CI with protocol deadlines matching launch deadlines; preserve owner CLI flags and capture child stderr, exits, shared-memory availability and concurrent Chrome processes in CI artifacts.
|
|
81
|
+
|
|
82
|
+
### Upgrade notes
|
|
83
|
+
|
|
84
|
+
- Existing restricted Stripe keys need Products and Prices Read for expanded checkout/subscription lines. Shop-only keys also need Subscriptions Read, Invoices Read and Customer portal Write for renewal verification and subscription management. Update both test and live keys in Stripe before deploying. Expanded objects require their own permissions: https://docs.stripe.com/stripe-apps/reference/permissions.
|
|
85
|
+
|
|
20
86
|
## [0.54.0] - 2026-10-15
|
|
21
87
|
|
|
22
88
|
**Plugin 0.54.0**
|
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@ to its own Cloudflare account with Wrangler. The homie.rocks directory lists the
|
|
|
8
8
|
|
|
9
9
|
Apps are one morphing screen, its roles and its parts: a customer phone, a staff tablet and a wall can share a 3D scene without rounds or scores. `homie-studio app new welcome` creates a working welcome queue in `apps/welcome/`; install its declared dependencies, build, then use `dev --lan` and the printed Wi-Fi address. `check welcome --url <origin>` proves a shared action and reconnect across three screens. Staff permissions, lasting D1 records, public QR/link helpers and customer standalone builds use the existing studio machinery. See [the app guide](apps/APPS.md).
|
|
10
10
|
|
|
11
|
+
For studio-specific JSON APIs, well-known files and OAuth callbacks, see [custom Worker routes and authorization](docs/custom-routes.md).
|
|
12
|
+
|
|
11
13
|
Most people never run this by hand: the Homie plugin for Claude Code, Codex and Grok does,
|
|
12
14
|
and the person approves Cloudflare once in their browser.
|
|
13
15
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -973,7 +973,7 @@ async function main() {
|
|
|
973
973
|
}
|
|
974
974
|
if (cmd === 'status') {
|
|
975
975
|
const studio = readStudio(root);
|
|
976
|
-
return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
|
|
976
|
+
return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && await whoami(root)) };
|
|
977
977
|
}
|
|
978
978
|
if (cmd === 'upgrade') {
|
|
979
979
|
const plan = upgradePlan(root);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Studio Worker routes
|
|
2
|
+
|
|
3
|
+
In `site/src/worker.mjs`, register one handler at module evaluation. Keep the
|
|
4
|
+
existing exports and registrations. Do not wrap the default export's `fetch`.
|
|
5
|
+
|
|
6
|
+
```js
|
|
7
|
+
import {useRoutes, isOwner, hasRole, requireSameOrigin} from '@homie-rocks/studio/worker';
|
|
8
|
+
|
|
9
|
+
useRoutes(async (request, env, ctx) => {
|
|
10
|
+
const url = new URL(request.url);
|
|
11
|
+
if (url.pathname === '/.well-known/example.json') {
|
|
12
|
+
return Response.json({name: 'My studio'});
|
|
13
|
+
}
|
|
14
|
+
if (url.pathname === '/api/custom/refresh') {
|
|
15
|
+
if (request.method !== 'POST') return new Response('POST required', {status:405});
|
|
16
|
+
const refused = requireSameOrigin(request);
|
|
17
|
+
if (refused) return refused;
|
|
18
|
+
if (!(await isOwner(request, env))) return new Response('Unauthorized', {status: 401});
|
|
19
|
+
// Validate input, then perform the owner-authorized mutation here.
|
|
20
|
+
return Response.json({ok: true});
|
|
21
|
+
}
|
|
22
|
+
// Return undefined or null to continue to the studio's static assets / 404.
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Homie's matched routes always take precedence, including its office, OAuth,
|
|
27
|
+
MCP, discovery, app and game routes, and their authorization refusals. Use a
|
|
28
|
+
separate namespace under `/api/custom/`, such as `/api/custom/callbacks/provider` for your OAuth callback;
|
|
29
|
+
`/oauth/*` belongs to Homie. Unmatched requests reach your handler before static
|
|
30
|
+
assets inside the Worker. Cloudflare normally serves existing public static files
|
|
31
|
+
without invoking the Worker. If a custom route must override such a file, put
|
|
32
|
+
`{"assets":{"run_worker_first":true}}` in the studio's `wrangler.custom.json`;
|
|
33
|
+
build/deploy preserves that setting. To intercept only selected files, use
|
|
34
|
+
`{"assets":{"run_worker_first":["/feed.json"]}}`. These paths are unioned with
|
|
35
|
+
Homie's generated `/games/*`, `/_studio/*`, `/_site/*` and per-app paths. Those
|
|
36
|
+
paths must reach the Worker to enforce private app authorization; custom arrays
|
|
37
|
+
(including empty arrays) or `false` cannot remove them. Exclusion patterns (starting with `!`) are refused at build, because they would take those paths out from behind their access checks. A second `useRoutes` call replaces the handler; `useRoutes(null)` removes
|
|
38
|
+
it. Register configuration only, never request data, at module scope.
|
|
39
|
+
|
|
40
|
+
The handler may return a `Response`, `null` or `undefined`. Errors propagate as
|
|
41
|
+
Worker errors. Responses keep streaming and pass through Homie's normal HTML
|
|
42
|
+
security headers, concept-mode handling and database change delivery. Custom
|
|
43
|
+
responses also receive `X-Content-Type-Options: nosniff` and a default
|
|
44
|
+
`Referrer-Policy: strict-origin-when-cross-origin`. WebSocket upgrades are preserved.
|
|
45
|
+
Use `ctx.waitUntil(promise)` for work that must finish after the response.
|
|
46
|
+
|
|
47
|
+
`await isOwner(request, env)` recognizes the live owner session issued by passkey
|
|
48
|
+
sign-in (including expiry and revocation), configured owner player accounts, and
|
|
49
|
+
trusted owner tool identity. Read/office bearer keys alone do not make a browser
|
|
50
|
+
the owner. `await hasRole(request, env, appId, roleName)` uses the app's declared
|
|
51
|
+
role and existing access checks: public roles permit visitors; private roles
|
|
52
|
+
require the same private link or handoff session and live grants as the app.
|
|
53
|
+
Unknown apps and roles return false. The explicit role argument takes precedence
|
|
54
|
+
over query parameters. These helpers authorize identity; custom mutations must
|
|
55
|
+
also validate the request method, Origin/CSRF protection, input and OAuth state.
|
|
56
|
+
|
|
57
|
+
`requireSameOrigin(request)` reuses the studio's browser Origin checks and returns
|
|
58
|
+
`null` on success or a 403 `Response` to return immediately. Use it with an explicit
|
|
59
|
+
POST (or other mutation method) check before cookie-authorized changes.
|
|
60
|
+
|
|
61
|
+
Use `/api/custom/*` for studio-owned endpoints: `api` is reserved from game and app
|
|
62
|
+
IDs, so future experiences cannot take over that namespace. An app handoff cookie
|
|
63
|
+
is scoped to `/<app>/`; the browser does **not** send it to `/custom-api/...` or
|
|
64
|
+
`/api/custom/...`. For app role mutations, use the built-in `/<app>/api/app/records/*`
|
|
65
|
+
endpoints or an app tool with its existing grant checks. A studio-wide custom
|
|
66
|
+
endpoint can use the owner session; do not broaden app cookies or assume a handoff
|
|
67
|
+
cookie will authorize `hasRole` outside its app path. Private/invite launches
|
|
68
|
+
also require launch access even for a declared anonymous visitor role.
|
|
69
|
+
|
|
70
|
+
For function HTTP calls whose destination is `self`, use direct `/api/` endpoints
|
|
71
|
+
such as `/api/custom/refresh`, not page URLs that redirect. Self calls use
|
|
72
|
+
`redirect:'error'`; a redirect fails instead of forwarding credentials.
|
package/guides/app/SKILL.md
CHANGED
|
@@ -365,3 +365,10 @@ Authoritative bookings/inventory are server-side `defineTool` actions with stric
|
|
|
365
365
|
Initialise text controls and navigation before 3D rendering. Catch renderer creation failures inside the scene section; keep fallback text in normal flow, never over navigation. Add a navigation need and run `homie-studio check <id> --webgl-failure --shots .checks/no-webgl` to prove the controls work without WebGL.
|
|
366
366
|
|
|
367
367
|
On TV, reserve `--homie-join-inset-bottom/right` (or the configured corner's top/left) plus a gap; `body.homie-join-visible` and `--homie-join-x/y/width/height` describe the shell card in the app frame too. See `node_modules/@homie-rocks/studio/docs/toolkit-place-patterns.md` for layout and text-first examples, connector setup and diagnostics.
|
|
368
|
+
|
|
369
|
+
For studio-owned HTTP routes, read
|
|
370
|
+
`node_modules/@homie-rocks/studio/docs/custom-routes.md`. Register with `useRoutes`
|
|
371
|
+
and use `/api/custom/*` (game/app IDs cannot claim `api`). App handoff cookies are
|
|
372
|
+
scoped to `/<app>/` and do not reach `/custom-api/...`; use built-in app record
|
|
373
|
+
endpoints or app tools for role-authorized mutations. Use direct `/api/` endpoints
|
|
374
|
+
for function `self` calls, which use `redirect:'error'`.
|
|
@@ -542,17 +542,19 @@ async function soundSession(base, game, out, seconds, gameJson) {
|
|
|
542
542
|
} finally { await close(h); }
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
-
/** A round with one person playing hard (a computer) and one doing nothing (a phone),
|
|
545
|
+
/** A round with one person playing hard (a computer) and one doing nothing (a phone), in the same public room. */
|
|
546
546
|
async function playSession(base, game, out, roundSeconds, gameJson) {
|
|
547
547
|
const profile = actionProfile(gameJson, 'desk');
|
|
548
548
|
const actions = newActions(profile);
|
|
549
549
|
const active = await launch('desk', `${base}/${game}/play`);
|
|
550
|
-
|
|
550
|
+
let idle;
|
|
551
551
|
try {
|
|
552
552
|
const seat = async (h) => { const until = Date.now() + 45_000; while (Date.now() < until) { const s = await shell(h); if (s?.room && s.seat !== null && s.seat !== undefined && s.role) return s; await sleep(300); } return null; };
|
|
553
|
-
const
|
|
553
|
+
const sa = await seat(active);
|
|
554
|
+
idle = await launch('phone', `${base}/${game}/play${sa?.room ? `?room=${encodeURIComponent(sa.room)}` : ''}`);
|
|
555
|
+
const si = await seat(idle);
|
|
554
556
|
if (!sa || !si) { row('play', 'FAIL', { why: 'a browser never got a seat in 45 s' }); return; }
|
|
555
|
-
if (sa.room !== si.room) { row('play', 'FAIL', { why: `the two browsers landed in different rooms (${sa.room}, ${si.room}):
|
|
557
|
+
if (sa.room !== si.room) { row('play', 'FAIL', { why: `the two browsers landed in different rooms (${sa.room}, ${si.room}): the invitation must join the same room` }); return; }
|
|
556
558
|
const since = Date.now();
|
|
557
559
|
// Only a round that STARTS after both are seated counts: a newcomer takes a bot's place mid-round and inherits
|
|
558
560
|
// its body and score, so the round already running says nothing about how these two played.
|
|
@@ -597,7 +599,7 @@ async function playSession(base, game, out, roundSeconds, gameJson) {
|
|
|
597
599
|
results: rowsR.map((x) => ({ place: x.place, score: x.score, bot: x.bot, who: x.seat === sa.seat ? 'the active player' : x.seat === si.seat ? 'the idle player' : x.bot ? 'a bot' : 'another person' })),
|
|
598
600
|
scoreSamples: scores.filter((_, k) => k % 5 === 0).slice(0, 40), shot: 'play-round-over.png',
|
|
599
601
|
});
|
|
600
|
-
} finally { await close(active); await close(idle); }
|
|
602
|
+
} finally { await close(active); if (idle) await close(idle); }
|
|
601
603
|
}
|
|
602
604
|
|
|
603
605
|
/** The studio's own checks, run as the creator would: the owner tests (port check) and the two-stranger round. */
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {readStudio,writeStudio,workerDir} from './studio.mjs';
|
|
2
|
+
import {cloudflareAuth} from './brain-probe.mjs';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
export const backgroundQueues=worker=>({work:worker.slice(0,48)+'-background',dead:worker.slice(0,48)+'-background-dlq'});
|
|
5
|
+
/** Names reserve this studio's queues. An interrupted pre-deploy creation has no
|
|
6
|
+
* bindings yet; inspect structured API records before resuming that reservation. */
|
|
7
|
+
export async function ensureBackgroundQueues(root,run,{accountId,fetchFn=fetch,headers}={}) {
|
|
8
|
+
const studio=readStudio(root),names=backgroundQueues(studio.cloudflare.worker);
|
|
9
|
+
accountId??=process.env.CLOUDFLARE_ACCOUNT_ID??studio.cloudflare.accountId;
|
|
10
|
+
for(const name of [names.dead,names.work]) {
|
|
11
|
+
if(readStudio(root).cloudflare.created?.includes('queue:'+name))continue;
|
|
12
|
+
const result=run(['queues','create',name]);
|
|
13
|
+
if(result.code!==0) {
|
|
14
|
+
headers??=await cloudflareAuth({bin:join(root,'node_modules/.bin/wrangler'),cwd:workerDir(root),exec:async(_bin,args)=>{const r=run(args);return {code:r.code,stdout:r.stdout??r.out};}});
|
|
15
|
+
const permission='The deployment token needs Account > Queues > Edit on this account. Add that permission to the CI/Workers Builds token (or sign in again with Wrangler), then retry deploy.';
|
|
16
|
+
if(!headers||!accountId)throw new Error(permission);
|
|
17
|
+
const response=await fetchFn(`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/queues?name=${encodeURIComponent(name)}`,{headers,signal:AbortSignal.timeout(15000)});
|
|
18
|
+
if(response.status===401||response.status===403)throw new Error(permission);
|
|
19
|
+
const body=await response.json();
|
|
20
|
+
if(!response.ok||body.success===false)throw new Error(`Could not inspect queue ${name} (HTTP ${response.status}). ${permission}`);
|
|
21
|
+
const queue=body.result?.find(q=>q.queue_name===name);
|
|
22
|
+
if(!queue)throw new Error(`Could not create queue ${name}. ${permission} Wrangler: ${result.out?.trim().split('\n').slice(-4).join(' ')}`);
|
|
23
|
+
const producers=queue.producers??[],consumers=queue.consumers??[];
|
|
24
|
+
if((queue.producers_total_count??producers.length)>producers.length||(queue.consumers_total_count??consumers.length)>consumers.length||
|
|
25
|
+
producers.some(p=>p.type!=='worker'||p.script!==studio.cloudflare.worker)||consumers.some(c=>c.type!=='worker'||(c.script_name??c.script)!==studio.cloudflare.worker))
|
|
26
|
+
throw new Error(`Queue ${name} is bound to another producer or consumer; it must belong exclusively to this studio`);
|
|
27
|
+
}
|
|
28
|
+
const latest=readStudio(root);
|
|
29
|
+
writeStudio(root,{...latest,cloudflare:{...latest.cloudflare,created:[...new Set([...(latest.cloudflare.created??[]),'queue:'+name])].sort()}});
|
|
30
|
+
}
|
|
31
|
+
}
|
package/lib/build.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {staticHeaders} from './static-assets.mjs';
|
|
1
2
|
import {verifiedMcpConfig,publicMcpSetup} from './shop-mcp.mjs';
|
|
2
3
|
import { directoryUrl } from '../worker/directory-choice.mjs';
|
|
3
4
|
import { domainOrigin } from './studio.mjs';
|
|
@@ -741,6 +742,8 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
741
742
|
const image = await playerImage(catalogue, game, dist, log);
|
|
742
743
|
if (image) game.playerImage = image;
|
|
743
744
|
}
|
|
745
|
+
const headerPath = join(dist, '_headers');
|
|
746
|
+
writeFileSync(headerPath, staticHeaders(existsSync(headerPath) ? readFileSync(headerPath, 'utf8') : '', {concept: studio.concept === true}));
|
|
744
747
|
writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
|
|
745
748
|
// What this build made, for whatever runs next (a deploy says which games it changes; a script checks the live
|
|
746
749
|
// site against it): per game its digest, its bundle and chunks, and whether it differs from the build before.
|
|
@@ -761,7 +764,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
761
764
|
await beforePublish();
|
|
762
765
|
const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.get(g.id)?.host === 'server').map((g) => g.id));
|
|
763
766
|
log('Building site Worker…');
|
|
764
|
-
await (await import('./worker-build.mjs')).buildWorker(root, esbuild);
|
|
767
|
+
await (await import('./worker-build.mjs')).buildWorker(root, esbuild, { catalogue, posts: posts.map(p => ({ ...p })) });
|
|
765
768
|
// All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
|
|
766
769
|
const swapped = swapIn(dist, live);
|
|
767
770
|
return {
|
package/lib/check.mjs
CHANGED
|
@@ -203,7 +203,14 @@ export async function check({ webglFailure = false, isolate = true, root = null,
|
|
|
203
203
|
const ua = await browser.userAgent();
|
|
204
204
|
await page.setUserAgent(`${ua} ${userAgentTag}`);
|
|
205
205
|
const t0 = Date.now();
|
|
206
|
-
|
|
206
|
+
let target = play;
|
|
207
|
+
if (players.length) {
|
|
208
|
+
const first = players[0].page;
|
|
209
|
+
await first.waitForFunction(() => Boolean(window.__shell?.room), {timeout: 90_000});
|
|
210
|
+
const room = await first.evaluate(() => window.__shell.room);
|
|
211
|
+
target = `${play}?room=${encodeURIComponent(room)}`;
|
|
212
|
+
}
|
|
213
|
+
try { await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 90_000 }); } catch (error) {
|
|
207
214
|
if (cannotAsk) return { ...cannotAsk, play, why: `${cannotAsk.why} (The ${kind.name} browser could not open it either: ${String(error?.message ?? error).split('\n')[0].slice(0, 120)}.)` };
|
|
208
215
|
throw error;
|
|
209
216
|
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {redactText} from './redact.mjs';
|
|
2
|
+
import {enableReadReplication} from './read-replication.mjs';
|
|
3
|
+
import {ensureBackgroundQueues,backgroundQueues} from './background-queue.mjs';
|
|
1
4
|
import { directoryUrl } from '../worker/directory-choice.mjs';
|
|
2
5
|
import { roomLayout } from '../worker/gate.mjs';
|
|
3
6
|
import {workerEntry} from './worker-build.mjs';
|
|
@@ -51,6 +54,13 @@ import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layou
|
|
|
51
54
|
|
|
52
55
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
53
56
|
|
|
57
|
+
/** Keep Wrangler's error and its context; progress/footer lines must not hide it. */
|
|
58
|
+
export function wranglerDeployError(out) {
|
|
59
|
+
const clean = redactText(String(out ?? '').replace(ANSI, '')).text;
|
|
60
|
+
const error = clean.search(/(?:^|\n)[^\n]*\[ERROR\]/);
|
|
61
|
+
return (error >= 0 ? clean.slice(error) : clean).trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
54
64
|
export function experienceAddresses(root, id, url) {
|
|
55
65
|
const app = listGames(root).find(g => g.id === id)?.kind === 'app';
|
|
56
66
|
return { [app ? 'open' : 'play']: url ? `${url}/${id}/${app ? 'open' : 'play'}` : null, wall: url ? `${url}/${id}/tv` : null };
|
|
@@ -65,24 +75,41 @@ export function wranglerBin(root) {
|
|
|
65
75
|
export function runner(root, env) {
|
|
66
76
|
const bin = wranglerBin(root);
|
|
67
77
|
if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
|
|
68
|
-
return (args, { cwd = workerDir(root), input } = {}) => {
|
|
78
|
+
return (args, { cwd = workerDir(root), input, timeout = 10 * 60_000 } = {}) => {
|
|
69
79
|
const dev = join(workerDir(root), '.wrangler', 'homie-dev.wrangler.json');
|
|
70
80
|
if (args.includes('--local') && !args.includes('--config') && existsSync(dev)) args = [...args, '--config', dev, '--persist-to', join(workerDir(root), '.wrangler', 'state')];
|
|
71
81
|
const res = spawnSync(bin, args, {
|
|
72
82
|
cwd, env: { ...process.env, ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
|
|
73
|
-
input, maxBuffer: 64 * 1024 * 1024, timeout
|
|
83
|
+
input, maxBuffer: 64 * 1024 * 1024, timeout,
|
|
74
84
|
});
|
|
75
|
-
const out = `${res.stdout ?? ''}${res.stderr ?? ''}`.replace(ANSI, '');
|
|
85
|
+
const out = `${res.stdout ?? ''}${res.stderr ?? ''}${res.error ? '\n' + res.error.message : ''}`.replace(ANSI, '');
|
|
76
86
|
return { code: res.status ?? 1, out, stdout: (res.stdout ?? '').replace(ANSI, '') };
|
|
77
87
|
};
|
|
78
88
|
}
|
|
79
89
|
|
|
90
|
+
/** Retry with fresh Wrangler processes: another studio may be rotating shared OAuth credentials. */
|
|
91
|
+
export async function checkWranglerLogin(root, env = {}, {run = null, sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
|
|
92
|
+
const bin = run ? null : wranglerBin(root);
|
|
93
|
+
if (!run && !bin) return {ok:false, needs:'cloudflare-login', why:'Wrangler is not installed in this studio yet: run `npm install` first.'};
|
|
94
|
+
const w = run ?? (args => wranglerRun(bin, args, {cwd:root, env:{...process.env,...env,WRANGLER_SEND_METRICS:'false',CI:'1'}, timeout:25_000}));
|
|
95
|
+
let res, data;
|
|
96
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
97
|
+
if (attempt) await sleep([200, 600, 1400][attempt - 1] + Math.floor(Math.random() * 100));
|
|
98
|
+
res = await w(['whoami', '--json'], {cwd: root, timeout: 25_000});
|
|
99
|
+
try { data = JSON.parse(res.stdout ?? ''); } catch { data = null; }
|
|
100
|
+
if (res.code === 0 && data?.loggedIn === false) return {ok:false, needs:'cloudflare-login', why:'Wrangler is not signed in. Run `npx wrangler login` and approve once in your browser, then retry.'};
|
|
101
|
+
if (res.code === 0 && data?.loggedIn === true) return {ok:true, who:{accounts:(data.accounts ?? []).map(a => ({id:a.id,name:a.name})),authType:data.authType}};
|
|
102
|
+
}
|
|
103
|
+
const detail = String(res.out ?? '').trim();
|
|
104
|
+
const expired = data?.loggedIn === false || /(?:refresh token.*(?:expired|invalid)|invalid_grant|not (?:logged|signed) in|authentication error|authenticate request)/i.test(detail);
|
|
105
|
+
return {ok:false, needs:expired ? 'cloudflare-login' : 'cloudflare-auth-check', why: (expired
|
|
106
|
+
? 'Wrangler could not authenticate after four attempts. Run `npx wrangler login`, then retry.'
|
|
107
|
+
: 'Wrangler could not check sign-in after four attempts. Another process may be refreshing credentials, or Cloudflare may be unreachable. Retry shortly.') + (detail ? '\nWrangler: '+detail : '')};
|
|
108
|
+
}
|
|
109
|
+
|
|
80
110
|
/** Who Wrangler is signed in as, or null. Never prints a token. */
|
|
81
|
-
export function whoami(root, env = {}) {
|
|
82
|
-
|
|
83
|
-
const res = w(['whoami', '--json'], { cwd: root });
|
|
84
|
-
if (res.code !== 0) return null;
|
|
85
|
-
try { const d = JSON.parse(res.stdout); return d.loggedIn ? { accounts: (d.accounts ?? []).map((a) => ({ id: a.id, name: a.name })), authType: d.authType } : null; } catch { return null; }
|
|
111
|
+
export async function whoami(root, env = {}) {
|
|
112
|
+
return (await checkWranglerLogin(root, env)).who ?? null;
|
|
86
113
|
}
|
|
87
114
|
|
|
88
115
|
function parseJson(text) {
|
|
@@ -101,6 +128,12 @@ function parseJson(text) {
|
|
|
101
128
|
export function explainCloudflare(out, accountId = null) {
|
|
102
129
|
const text = String(out ?? '');
|
|
103
130
|
const dash = accountId ? `https://dash.cloudflare.com/${accountId}` : 'https://dash.cloudflare.com';
|
|
131
|
+
if (/queue.*(?:permission|authentication|forbidden|unauthori)|(?:permission|authentication|forbidden|unauthori).*queue/is.test(text)) {
|
|
132
|
+
return {needs:'queues-permission',why:'The deployment token needs Account > Queues > Edit on this account. Update the CI/Workers Builds token permissions, then retry deploy.'};
|
|
133
|
+
}
|
|
134
|
+
if (/(?:cron|schedule).*?(?:limit|maximum|exceed)|(?:limit|maximum|exceed).*?(?:cron|schedule)/i.test(text)) {
|
|
135
|
+
return { needs: 'cron-limit', why: 'This account has reached its Cron Trigger limit (5 on Workers Free). Remove unused triggers from other Workers in the Cloudflare dashboard, or upgrade the account plan, then retry deploy. Required studio schedules have been retained.' };
|
|
136
|
+
}
|
|
104
137
|
if (/\b10034\b|verify your email/i.test(text)) {
|
|
105
138
|
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. The AI resumes deployment after verification.` };
|
|
106
139
|
}
|
|
@@ -210,6 +243,9 @@ export function deployPlan(root) {
|
|
|
210
243
|
{ kind: 'Durable Object', name: 'Table', what: hosted.length ? `one per public room: the netplay relay (seats, snapshots), and for ${hosted.map((h) => h.id).join(', ')} the game's own rules, which run here and nowhere else` : 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker' },
|
|
211
244
|
...hosted.map((h) => ({ kind: 'Game rules', name: h.id, what: `One full ${h.seats}-player room activates ${h.objects} objects (${h.layout.gates > 1 ? `${h.layout.gates} Gates, the room and its diagnostics` : 'the room'}): approximately ${h.gbSeconds} GB-seconds/hour before Lobby activity. Player input/ping alone contributes ${h.requests} request units/hour; relay frames and receipts, HTTP and storage are additional. Occupancy, batching and account usage determine the bill.`, state: 'in the Worker' })),
|
|
212
245
|
{ kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker' },
|
|
246
|
+
...['Gate','Concentrator','AppRecordHub','EventHub'].map(name=>({kind:'Durable Object',name,what:name==='Gate'?'room transport and diagnostics':name==='Concentrator'?'compatibility for existing studios':'independent live subscriptions',state:'declared by the Worker'})),
|
|
247
|
+
...Object.values(backgroundQueues(cf.worker)).map(name=>({kind:'Queue',name,what:'durable background delivery with retries and a dead-letter queue',state:mark('queue:'+name)})),
|
|
248
|
+
...(studio.stats?.analytics===true ? [{kind:'Analytics Engine',name:`${cf.worker.replace(/-/g,'_')}_visits`,what:'page counts, rolled into historical D1 totals hourly',state:'bound by the Worker'}] : []),
|
|
213
249
|
storage
|
|
214
250
|
? { kind: 'R2 bucket', name: storage, what: 'the studio\'s big media: songs and videos over the size studio.json media.r2Over names (1 MiB unless set) or that git leaves out, uploaded and checked by SHA-256 before the site stops carrying them, served at their same addresses', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
|
|
215
251
|
: { 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 },
|
|
@@ -251,12 +287,13 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
251
287
|
const wide = wideRouteRefusal(readConfig(root), studio);
|
|
252
288
|
if (wide) return { ok: false, command: 'deploy', ...wide };
|
|
253
289
|
let routes = keptRoutes(root, studio);
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
290
|
+
const login = await checkWranglerLogin(root);
|
|
291
|
+
const who = login.who;
|
|
292
|
+
if (!login.ok) {
|
|
293
|
+
if (cf.auth === 'stripe-projects' && login.needs === 'cloudflare-login') {
|
|
294
|
+
return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: login.why + '\nWrangler did not accept the Cloudflare token Stripe Projects keeps for this studio (studio.json "auth": "stripe-projects"). Run `stripe projects env --pull` in the studio folder (it rewrites the git-ignored .env from Projects\' vault), then `npm run deploy` again; or sign in the usual way (`npx wrangler login`) and remove "auth" from studio.json.' };
|
|
258
295
|
}
|
|
259
|
-
return {
|
|
296
|
+
return { ...login, command: 'deploy' };
|
|
260
297
|
}
|
|
261
298
|
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
|
|
262
299
|
if (!accountId) {
|
|
@@ -384,8 +421,10 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
384
421
|
step(`Cron triggers: ${(readConfig(root)?.triggers?.crons??[]).join("; ")||"none (remove toolkit triggers)"}`);
|
|
385
422
|
if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
|
|
386
423
|
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
424
|
+
try {await ensureBackgroundQueues(root,w,{accountId,...(fetchFn?{fetchFn}:{})});for(const key of readStudio(root).cloudflare.created??[])created.add(key);} catch(error) {return refuse(error.message);}
|
|
387
425
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
388
426
|
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
427
|
+
try {const replication=await enableReadReplication(root,w,{accountId,databaseId:db.uuid,...(fetchFn?{fetchFn}:{})});step(replication.enabled?'D1 read replication enabled':`D1 uses the primary: ${replication.reason}`);} catch(error) {step(`D1 uses the primary: ${error.message}`);}
|
|
389
428
|
step('D1 migrations applied');
|
|
390
429
|
const subscriptions=w(['d1','execute',cf.d1,'--remote','--command',(await import('./functions-build.mjs')).functionDeploymentSQL(root)]);
|
|
391
430
|
if(subscriptions.code!==0)return refuse('Could not register studio functions',subscriptions.out);
|
|
@@ -400,7 +439,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
400
439
|
step(address.created ? 'registered the account’s workers.dev address automatically' : 'kept the account’s existing workers.dev address');
|
|
401
440
|
dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
|
|
402
441
|
}
|
|
403
|
-
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out
|
|
442
|
+
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${wranglerDeployError(dep.out)}`, dep.out);
|
|
404
443
|
if (paidReleases(root).length) {
|
|
405
444
|
const bin = wranglerBin(root);
|
|
406
445
|
const uploadOptions = { cwd: workerDir(root), env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: accountId, WRANGLER_SEND_METRICS: 'false', CI: '1' } };
|
|
@@ -473,7 +512,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
473
512
|
|
|
474
513
|
// studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
|
|
475
514
|
const { url: _url, deployedAt: _at, ...kept } = cf;
|
|
476
|
-
const next = { ...kept, ...(cf.url && !isWorkersDev(cf.url) ? { url: cf.url } : {}), accountId, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...created].sort() };
|
|
515
|
+
const next = { ...kept, ...(cf.url && !isWorkersDev(cf.url) ? { url: cf.url } : {}), accountId, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...new Set([...created,...(readStudio(root).cloudflare.created??[])])].sort() };
|
|
477
516
|
writeStudio(root, { ...readStudio(root), cloudflare: next });
|
|
478
517
|
return {
|
|
479
518
|
ok: true, command: 'deploy', url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
|
|
@@ -604,6 +643,7 @@ export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
|
|
|
604
643
|
const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
|
|
605
644
|
if (!existsSync(join(root, 'site', 'dist', 'games.json'))) return { ok: false, command: 'deploy', ci: true, why: 'nothing is built: the build command is `npm run build` (homie-studio build), and it runs before this', steps };
|
|
606
645
|
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
646
|
+
try {await ensureBackgroundQueues(root,w);} catch(error) {return refuse(error.message);}
|
|
607
647
|
const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
|
|
608
648
|
let migrate = apply();
|
|
609
649
|
const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
|
|
@@ -623,7 +663,7 @@ export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
|
|
|
623
663
|
}
|
|
624
664
|
const started = Date.now();
|
|
625
665
|
const dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
|
|
626
|
-
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out
|
|
666
|
+
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${wranglerDeployError(dep.out)}`, dep.out);
|
|
627
667
|
step(`deployed ${names.worker ?? 'the Worker'} in ${Math.round((Date.now() - started) / 1000)} s`);
|
|
628
668
|
const repo = repoOf(root);
|
|
629
669
|
step(repo ? `the live site knows its GitHub repository (${repo}), for the Claude app's hand-off` : 'no GitHub repository found here (no HOMIE_REPO, studio.json "github" or git remote): the Claude app\'s card asks the person which it is');
|
|
@@ -633,6 +673,7 @@ export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
|
|
|
633
673
|
step('D1 migrations applied (the database was created with this first deploy)');
|
|
634
674
|
const registered=register();if(registered.code!==0)return refuse('Could not register studio functions',registered.out);
|
|
635
675
|
}
|
|
676
|
+
try {const replication=await enableReadReplication(root,w);step(replication.enabled?'D1 read replication enabled':`D1 uses the primary: ${replication.reason}`);} catch(error) {step(`D1 uses the primary: ${error.message}`);}
|
|
636
677
|
// The live address, from what Wrangler printed (a custom domain in studio.json wins).
|
|
637
678
|
const workersDev = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? null;
|
|
638
679
|
let url = null;
|
|
@@ -668,8 +709,9 @@ export async function storageAdd(root, { log = () => {} } = {}) {
|
|
|
668
709
|
if (created.has(`r2:${bucket}`)) {
|
|
669
710
|
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)'] };
|
|
670
711
|
}
|
|
671
|
-
const
|
|
672
|
-
if (!
|
|
712
|
+
const login = await checkWranglerLogin(root);
|
|
713
|
+
if (!login.ok) return {...login, command:'storage add'};
|
|
714
|
+
const who = login.who;
|
|
673
715
|
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
|
|
674
716
|
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 };
|
|
675
717
|
const w = runner(root, { CLOUDFLARE_ACCOUNT_ID: accountId });
|
|
@@ -703,15 +745,15 @@ export async function storageAdd(root, { log = () => {} } = {}) {
|
|
|
703
745
|
}
|
|
704
746
|
|
|
705
747
|
/** One Wrangler command, run without blocking (an upload of a music video takes a while); the last of its words kept. */
|
|
706
|
-
function wranglerRun(bin, args, { cwd, env }) {
|
|
748
|
+
function wranglerRun(bin, args, { cwd, env, timeout }) {
|
|
707
749
|
return new Promise((done) => {
|
|
708
|
-
const p = spawn(bin, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
709
|
-
let out = '';
|
|
750
|
+
const p = spawn(bin, args, { cwd, env, timeout, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
751
|
+
let out = '', stdout = '';
|
|
710
752
|
const keep = (d) => { out = `${out}${d}`.slice(-8000); };
|
|
711
|
-
p.stdout.on('data', keep);
|
|
753
|
+
p.stdout.on('data', data => {stdout = `${stdout}${data}`.slice(-65536); keep(data);});
|
|
712
754
|
p.stderr.on('data', keep);
|
|
713
755
|
p.on('error', (e) => done({ code: 1, out: String(e.message) }));
|
|
714
|
-
p.on('close', (code) => done({ code: code ?? 1, out: out.replace(ANSI, '') }));
|
|
756
|
+
p.on('close', (code) => done({ code: code ?? 1, out: out.replace(ANSI, ''), stdout: stdout.replace(ANSI, '') }));
|
|
715
757
|
});
|
|
716
758
|
}
|
|
717
759
|
|
package/lib/dev.mjs
CHANGED
|
@@ -347,17 +347,15 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
347
347
|
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
348
348
|
const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1', ...(process.stdout.isTTY && !process.env.NO_COLOR ? { FORCE_COLOR: process.env.FORCE_COLOR ?? '1' } : {}) };
|
|
349
349
|
for (const added of ensureMigrations(root)) log(`added ${added} (${migrationWord(added)})`);
|
|
350
|
-
await new Promise((done) => {
|
|
351
|
-
const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
352
|
-
m.on('close', done);
|
|
353
|
-
m.on('error', done);
|
|
354
|
-
});
|
|
355
350
|
const {functionDeploymentSQL}=await import('./functions-build.mjs');
|
|
356
|
-
const
|
|
357
|
-
|
|
351
|
+
const {prepareLocalDatabase}=await import('./local-database-start.mjs');
|
|
352
|
+
const runLocal=args=>new Promise(resolve=>{
|
|
353
|
+
const child=spawn(bin,args,{cwd:workerDir(root),env,stdio:['ignore','ignore','inherit']});
|
|
358
354
|
child.on('close',code=>resolve(code===0));child.on('error',()=>resolve(false));
|
|
359
355
|
});
|
|
360
|
-
|
|
356
|
+
const registerFunctions=()=>runLocal(['d1','execute','DB','--local','--command',functionDeploymentSQL(root)]);
|
|
357
|
+
const failure=await prepareLocalDatabase({migrate:()=>runLocal(['d1','migrations','apply','DB','--local']),register:registerFunctions});
|
|
358
|
+
if(failure)return {ok:false,command:'dev',why:failure};
|
|
361
359
|
const origin = `http://127.0.0.1:${port}`;
|
|
362
360
|
const printedOrigin = `http://localhost:${port}`;
|
|
363
361
|
log(`Local site: ${printedOrigin}/ (each game: ${printedOrigin}/<id>/play — open it in two browsers)`);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Complete local schema and subscriptions before any request can emit an event. */
|
|
2
|
+
export async function prepareLocalDatabase({migrate,register}) {
|
|
3
|
+
if(!await migrate())return 'Local database migration failed; fix the D1 error above before serving requests.';
|
|
4
|
+
if(!await register())return 'Local function registration failed; fix the D1 error above before serving requests.';
|
|
5
|
+
return null;
|
|
6
|
+
}
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -843,7 +843,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
843
843
|
let at = dir;
|
|
844
844
|
if (existsSync(at) && readdirSync(at).some((f) => !['.DS_Store', '.git'].includes(f)) && !existsSync(join(at, 'studio.json'))) at = `${dir}-studio`;
|
|
845
845
|
let made;
|
|
846
|
-
try { made = newStudio(at, { name, kind: a.kind ?? 'game', install: false
|
|
846
|
+
try { made = newStudio(at, { name, kind: a.kind ?? 'game', install: false }); } catch (error) { return fail(error.message); }
|
|
847
847
|
ctx.current = made.dir;
|
|
848
848
|
ctx.note(made.dir).earlier = earlier.map((e) => e.path);
|
|
849
849
|
let install = null;
|
|
@@ -1258,10 +1258,10 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1258
1258
|
// the directory's read-only count for this site, the parts' lines, and nothing published. Run here, by THIS
|
|
1259
1259
|
// toolkit, never handed to the studio's pinned one: a 0.31.0 toolkit reads `publish --before` as `publish`.
|
|
1260
1260
|
if (a.before === true) {
|
|
1261
|
-
const b = await publishBefore(root, { site: a.site ? String(a.site) : undefined });
|
|
1261
|
+
const b = await publishBefore(root, { homie: ctx.directory ?? undefined, site: a.site ? String(a.site) : undefined });
|
|
1262
1262
|
return ok(b.lines.join('\n'), { kind: 'publish-count', ...publishesSoFar(root), ...b });
|
|
1263
1263
|
}
|
|
1264
|
-
const r = await cli(ctx, root, 'publish', ['publish', ...(a.site ? ['--site', String(a.site)] : [])]);
|
|
1264
|
+
const r = await cli(ctx, root, 'publish', ['publish', ...(ctx.directory ? ['--homie', ctx.directory] : []), ...(a.site ? ['--site', String(a.site)] : [])]);
|
|
1265
1265
|
if (!r.ended) return stillRunning(r.job, 'Listing');
|
|
1266
1266
|
if (r.job.code !== 0) return fail(`Not listed: ${whyOf(r.job)}`, r.result?.publishes ? { kind: 'publish', ok: false, needs: r.result.needs ?? null, publishes: r.result.publishes } : undefined);
|
|
1267
1267
|
return ok(`Listed: ${r.result?.studioPage ?? r.result?.directory ?? ''}\n${(r.result?.games ?? []).map((g) => ` ${g.name}: ${g.play}`).join('\n')}${r.result?.publishes?.line ? `\n${r.result.publishes.line}` : ''}${partsSaid.length ? `\n${partsSaid.join('\n')}` : ''}`, { kind: 'publish', ...r.result });
|
package/lib/port-check.mjs
CHANGED
|
@@ -628,12 +628,13 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
628
628
|
}
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
-
/* ----
|
|
631
|
+
/* ---- a public join and an invited browser finish a round */
|
|
632
632
|
if (want.has('round') && await gate('round')) {
|
|
633
633
|
try {
|
|
634
634
|
const A = await launch('desk');
|
|
635
635
|
const B = await launch('phone');
|
|
636
|
-
const
|
|
636
|
+
const oa = await open(A, `${base}/${game}/play?name=Desk`);
|
|
637
|
+
const ob = await open(B, `${base}/${game}/play?name=Phone${oa.shell?.room ? `&room=${encodeURIComponent(oa.shell.room)}` : ''}`);
|
|
637
638
|
if (!oa.ok || !ob.ok) row('round', false, { why: `a browser was never seated (${!oa.ok ? `computer: ${oa.why}` : ''} ${!ob.ok ? `phone: ${ob.why}` : ''})` });
|
|
638
639
|
else if (oa.shell.room !== ob.shell.room) row('round', false, { why: `the two browsers landed in different rooms (${oa.shell.room}, ${ob.shell.room})` });
|
|
639
640
|
else {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {cloudflareAuth} from './brain-probe.mjs';
|
|
2
|
+
import {readConfig} from './routes.mjs';
|
|
3
|
+
import {readStudio,workerDir} from './studio.mjs';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
/** Enable replicas on this studio's database using the same login as deployment.
|
|
6
|
+
* A missing account/id (first Workers Builds provisioning) is retried next deploy.
|
|
7
|
+
* Auth failures are reported; the primary-first runtime remains consistent.
|
|
8
|
+
*/
|
|
9
|
+
export async function enableReadReplication(root, run, {accountId, databaseId, fetchFn=fetch}={}) {
|
|
10
|
+
const studio=readStudio(root);
|
|
11
|
+
accountId??=process.env.CLOUDFLARE_ACCOUNT_ID??studio.cloudflare?.accountId;
|
|
12
|
+
databaseId??=readConfig(root)?.d1_databases?.find(db=>db.binding==='DB')?.database_id;
|
|
13
|
+
if (!accountId || !databaseId) return {enabled:false,reason:'database provisioning has not supplied its account and id'};
|
|
14
|
+
if (process.env.HOMIE_STUDIO_WARM==='0') return {enabled:false,reason:'offline test'};
|
|
15
|
+
try {
|
|
16
|
+
const headers=await cloudflareAuth({bin:join(root,'node_modules/.bin/wrangler'),cwd:workerDir(root),exec:async(_bin,args)=>{const r=run(args);return {code:r.code,stdout:r.stdout??r.out};}});
|
|
17
|
+
if (!headers) return {enabled:false,reason:'Cloudflare authentication unavailable'};
|
|
18
|
+
const url=`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/d1/database/${encodeURIComponent(databaseId)}`;
|
|
19
|
+
const response=await fetchFn(url,{method:'PATCH',headers:{...headers,'content-type':'application/json'},body:JSON.stringify({read_replication:{mode:'auto'}}),signal:AbortSignal.timeout(15000)});
|
|
20
|
+
const body=await response.json();
|
|
21
|
+
if(!response.ok||body.success===false||body.result?.read_replication?.mode!=='auto')throw new Error(`Could not enable D1 read replication (HTTP ${response.status}); the deployment login needs D1 Edit`);
|
|
22
|
+
return {enabled:true};
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return {enabled:false,reason:`D1 uses the primary; read replication could not be enabled: ${error.message}`};
|
|
25
|
+
}
|
|
26
|
+
}
|