Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions openspec/changes/mapgen-studio-bun-server/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Design — Standalone Bun daemon for the Studio server surface

## Current state (verified 2026-06-12)

- `vite.config.ts` (1,420 lines) carries: ~950 lines of engine code at
module scope (queue, two operation stores, instance identity, autoplay /
run-in-game / save-deploy engines), 16 legacy REST handlers, and three
oRPC mounts (`/rpc` studio-server, `/api/civ7/rpc` control,
`/api/recipe-dag/rpc` recipe-DAG via per-request `ssrLoadModule`).
- The client is already 100% oRPC: zero manual `/api/*` fetches in app
source. The only `/api` strings client-side are the two oRPC path
constants. (P2 client-data slice did the cutover of reads.)
- `@civ7/studio-server`'s handler is already the A4-lite fetch shape
(`Request`/`Response`); the recipe-DAG handler likewise
(`createStudioRecipeDagRpcHandler`). Only the control mount still uses
the node adapter (`@orpc/server/node`).
- Production (`railway.json`/Caddy) serves static `dist` only — no server.

## Topology decision

Blueprint: `tools/gt-stack-inspect` on
`codex/gt-stack-inspect-dev-live-topology` (read-only prior art) — a Bun
daemon (`Bun.serve({ fetch })` over a fetch-adapter RPC handler + static
assets + `/healthz`), a `dev-live` runner spawning daemon then Vite, and a
Vite proxy for the RPC prefix. The studio adopts the same shape:

```
bun run dev
└─ src/server/daemon/devLive.ts (runner)
├─ spawns: bun src/server/daemon/daemon.ts --port 5174 (backend)
│ Bun.serve fetch router:
│ /healthz → runtime health (json)
│ /rpc/* → createStudioRpcHandler (studio-server)
│ /api/civ7/rpc/* → control-oRPC fetch handler
│ /api/recipe-dag/rpc/* → recipe-DAG fetch handler (static import)
│ /api/* → legacy REST compat (same engines)
│ static dist (optional, --assets-root; prod story)
└─ spawns: vite --port 5173 (frontend)
server.proxy: /rpc → 5174, /api → 5174
```

- Ports: Vite keeps 5173 (`strictPort`); daemon defaults to 5174.
`STUDIO_DEV_RPC_TARGET` overrides the proxy target so `vite` alone still
works against an already-running daemon.
- The daemon resolves `repoRoot` from its own module location by default
(`--repo-root` overrides), mirroring the engines' previous
`vite.config.ts`-relative resolution.
- `Bun.serve` is typed via a minimal local `declare const Bun` (blueprint
pattern) — no new dependency; the module is executed only under Bun.

## Engine extraction (slice 2 — no topology change)

`createStudioEngines({ repoRoot })` in `src/server/studio/engines.ts` owns
all process-singleton state and returns the five engine functions plus
identity. Moved **verbatim** from `vite.config.ts`; the only edits are the
factory closure and `repoRoot` parameterization (previously recomputed per
call via `fileURLToPath(new URL("../..", import.meta.url))`).

`createStudioServerContext({ engines, hostCommand })` in
`src/server/studio/context.ts` is the moved `createStudioServerContextForApp`
— the `RunInGameHttpError → ORPCError` mapping that preserves the
non-uniform status codes (arch/10 §1 parity invariants).

Module constraint (load-bearing): `engines.ts` imports
`@civ7/direct-control` + local server modules only — **never
`effect-orpc`** — so the interim static import into `vite.config.ts` stays
node-evaluable. `@civ7/studio-server` is safe (tsup bundles effect-orpc
into its dist); app-src effect-orpc consumers (the recipe-DAG router) stay
out of the config graph until the daemon slice removes the need entirely.

Slice 2 leaves Vite hosting everything — the config consumes the extracted
module at module scope, identical behavior, all transports still share one
engine instance per process.

## Control-oRPC fetch adapter (slice 3)

`civ7ControlOrpc.ts` re-shapes to mirror `recipeDag/orpc.ts`: canonical
`createStudioCiv7ControlRpcHandler` on `RPCHandler` from
`@orpc/server/fetch` (prefix `STUDIO_CIV7_CONTROL_ORPC_PATH`), plus the
Connect shim over `server/http/nodeWebBridge` for the Vite mount this
slice. Context construction (`directControl` facade + `endpointDefaults`)
unchanged. Path contract unchanged.

## Daemon + cutover (slice 4)

- `src/server/daemon/daemon.ts`: arg parsing (`--host`, `--port`,
`--repo-root`, `--assets-root`), one `createStudioEngines` instance, the
three fetch handlers, the legacy compat router, `/healthz`, optional
static serving with SPA fallback. The fetch composition is exported as a
pure `createStudioDaemonFetch(deps)` so route dispatch is unit-testable
under vitest without `Bun.serve`.
- `src/server/studio/legacyHttp.ts`: the 16 REST handlers re-expressed as
fetch handlers over the same engines — **byte-equal response bodies and
status codes**, including the parity pins: run-in-game status 404 echoes
`serverInstanceId`/`serverStartedAt`; save-deploy status 404 does not;
autoplay/save-deploy/run-in-game non-uniform codes; `live/status` returns
200 with per-field embedded `{error}`.
- `vite.config.ts`: the `configureServer` plugin and every server-side
import are deleted; `server.proxy` forwards `/rpc` and `/api` to the
daemon. The config no longer drags `@civ7/direct-control` or any engine
code — config restarts become cheap and safe (the silent-restart-failure
trap documented in the dag-tab addendum loses its teeth).
- `package.json`: `dev` → dev-live runner; `dev:frontend` → `vite`;
`dev:server` → daemon. `build`/`test`/`check` unchanged.
- `studio.serverInfo.viteCommand` (contract: `z.string()`) is supplied by
the host: the daemon passes `"daemon"`. Verified no client branches on
the value (display/identity only); `serverInstanceId`/`startedAt` restart
detection is unaffected (identity now lives in the daemon process —
correct: it owns the operation stores the client reconciles against).

## Legacy retirement list (checkpoint artifact — NOT auto-run)

The compat surface below is pending an explicit user checkpoint before
deletion. Consumer evidence (repo-wide sweep 2026-06-12): the studio client
calls none of these (oRPC-only since P2); the single live consumer is
`scripts/civ7-direct-control/verify-final-surface-parity.ts` (hits
`GET /api/civ7/run-in-game/status`); `packages/civ7-direct-control/README.md`
documents three of them as examples.

| # | Legacy path | oRPC equivalent (`/rpc`) |
| --- | --- | --- |
| 1 | `GET /api/civ7/status` | `civ7.status` |
| 2 | `GET /api/civ7/map-summary` | `civ7.mapSummary` |
| 3 | `GET /api/civ7/gameinfo` | `civ7.gameInfo` |
| 4 | `GET /api/civ7/live/status` | `civ7.live.status` |
| 5 | `GET /api/civ7/live/snapshot` | `civ7.live.snapshot` |
| 6 | `GET /api/civ7/live/entities` | `civ7.live.entities` |
| 7 | `GET /api/civ7/live/gameinfo` | `civ7.live.gameInfo` |
| 8 | `POST /api/civ7/autoplay` | `civ7.autoplay` |
| 9 | `GET /api/studio/server-info` | `studio.serverInfo` |
| 10 | `GET /api/civ7/setup-config` | `civ7.setupConfig` |
| 11 | `GET /api/civ7/saved-configs` | `civ7.savedConfigs` |
| 12 | `GET /api/civ7/setup-catalog` | `civ7.setupCatalog` |
| 13 | `GET /api/civ7/run-in-game/status` | `runInGame.status` (+ parity script) |
| 14 | `POST /api/civ7/run-in-game` | `runInGame.start` |
| 15 | `GET /api/map-configs/status` | `mapConfigs.status` |
| 16 | `POST /api/map-configs` | `mapConfigs.saveDeploy` |

Retirement slice (post-checkpoint): delete `legacyHttp.ts` + its daemon
mount + tests; update or retire the parity script's status poll.

## Testing

- Existing pins unchanged: `test/recipeDag/orpc.test.ts` (node transport
via the Connect shim), operation-state suites, watch-ignore pins.
- New: daemon route-dispatch tests over `createStudioDaemonFetch` with
injected handlers (prefix routing, healthz, 404 fall-through, static
fallback); control fetch-adapter transport test (mirror of the recipe-DAG
one); legacy compat parity pins (404-echo asymmetry, non-uniform status
codes, `live/status` embedded-error 200); dev-live plan test
(`makeDevLivePlan` pure function: commands, ports, proxy env).
- Live smoke (fresh processes, both run modes): `bun run dev` →
generation run completes; pipeline view loads the DAG through the proxy;
control readiness poll works; curl pins on `/healthz`, a legacy path, and
`studio.serverInfo`.
90 changes: 90 additions & 0 deletions openspec/changes/mapgen-studio-bun-server/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Studio server cutover — standalone Bun daemon owns the server surface

## Why

Today the Studio has no server process of its own: every server-side surface
(the `@civ7/studio-server` `/rpc` mount, the control-oRPC mount at
`/api/civ7/rpc`, the recipe-DAG mount at `/api/recipe-dag/rpc`, and 16
hand-rolled legacy `/api/*` REST handlers) runs as Vite dev middleware, with
the stateful engines (serialized operation queue, run-in-game + save/deploy
operation stores, server instance identity) living at module scope inside
`vite.config.ts`. Production is a static SPA with no server at all, so the
dev semantics are the only semantics — and they are welded to the dev
bundler. This was the planned P5a coexistence state; the P5b cutover
(00-GOAL "Remaining (SUPERVISED)", FRAME §4.7 deferral, user go granted
2026-06-11) moves the server surface onto a standalone Bun daemon and makes
Vite frontend-only.

Bun also dissolves a real defect class: `effect-orpc` ships TypeScript
source as its package entry, which Node cannot load outside Vite's SSR
pipeline — forcing the recipe-DAG mount through per-request `ssrLoadModule`
(see `mapgen-studio-dag-tab` design addendum). Bun loads TS natively; the
daemon imports the same fetch handler statically.

## Target Authority Refs

- `docs/projects/mapgen-studio-redesign/architecture/10-target-architecture.md`
(§1 server target + parity invariants, §7 do-not-break registry)
- `docs/projects/mapgen-studio-redesign/00-GOAL.md` (P5b entry — supervised,
go granted)
- `packages/studio-server/src/{index,handler}.ts` (A4-lite host seam:
fetch-adapter `createStudioRpcHandler`)
- `openspec/changes/mapgen-studio-dag-tab/design.md` addendum (mount
constraints + research evidence: fetch handlers are the canonical
artifacts, Vite hosting was a dev-era necessity)
- `tools/gt-stack-inspect` on `codex/gt-stack-inspect-dev-live-topology`
(prior-art daemon + dev-live runner + Vite `/rpc` proxy topology;
read-only reference — `codex/*` branches are never touched)

## What Changes

- **Engines move out of `vite.config.ts`** into
`apps/mapgen-studio/src/server/studio/engines.ts`: a
`createStudioEngines({ repoRoot })` factory owning the serialized
operation queue, both operation stores, the server instance identity, and
the five engine functions (autoplay, run-in-game start/status,
save-deploy start/status) — moved verbatim, behavior-parity hard core.
The `StudioServerContext` builder moves alongside as
`createStudioServerContext`. (This slice changes no topology: Vite still
hosts everything, now via the extracted module.)
- **Control-oRPC mount converts to the fetch adapter**
(`@orpc/server/fetch`), mirroring the recipe-DAG mount's shape: canonical
fetch handler + thin Connect shim over the shared
`server/http/nodeWebBridge`. Path contract `/api/civ7/rpc` unchanged.
- **Standalone Bun daemon** (`src/server/daemon/daemon.ts`): `Bun.serve`
hosting a fetch router that owns `/healthz`, `/rpc` (studio-server),
`/api/civ7/rpc` (control-oRPC), `/api/recipe-dag/rpc` (statically
imported — the `ssrLoadModule` constraint evaporates under Bun), the
legacy `/api/*` REST surface as a compat layer over the same engines, and
optional static `dist` serving (the production story's opening, not wired
to deploy in this change).
- **Vite becomes frontend-only**: the `configureServer` plugin and all
server-side imports leave `vite.config.ts`; `server.proxy` forwards
`/rpc` and `/api` to the daemon. Dev topology: `bun run dev` runs a
dev-live runner that spawns the daemon (port 5174), waits for `/healthz`,
then spawns Vite (port 5173).
- **Legacy `/api/*` REST handlers**: ported onto the daemon as a thin compat
module (same engines, same response bodies and status codes) so the
cutover lands with zero observable surface change. Their **retirement is
gated on an explicit user checkpoint** (supervision condition) and is NOT
part of this change's auto-run scope; the retirement list and consumer
evidence live in `design.md`.

## Non-Goals

- No retirement of the legacy REST surface without the user checkpoint.
- No Railway/Caddy production deploy changes (the daemon's static serving
makes them possible later; wiring them is out of scope).
- No contract changes to `@civ7/studio-server`, `@civ7/control-orpc`, or
the recipe-DAG router; no client-visible transport changes.
- No re-implementation of engine logic — the engine bodies move verbatim.

## Impact

- Affected specs: `mapgen-studio`
- Affected code: `apps/mapgen-studio/vite.config.ts` (shrinks to
frontend-only), `apps/mapgen-studio/src/server/studio/*` (new: engines,
context, legacy compat), `apps/mapgen-studio/src/server/daemon/*` (new:
daemon, dev-live runner), `apps/mapgen-studio/src/server/civ7ControlOrpc.ts`
(fetch adapter), `apps/mapgen-studio/package.json` (dev scripts),
`apps/mapgen-studio/test/devServer/*` + `test/server/*` (new pins)
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
## ADDED Requirements

### Requirement: The Studio Server Surface Runs As A Standalone Bun Daemon

The Studio's server surface SHALL be owned by a standalone Bun daemon
process hosting the studio-server `/rpc` mount, the control-oRPC mount at
`/api/civ7/rpc`, the recipe-DAG mount at `/api/recipe-dag/rpc`, and the
legacy `/api/*` REST compat surface, with the Vite dev server reduced to a
frontend-only role that proxies `/rpc` and `/api` to the daemon. All path
contracts and client transports are unchanged by the cutover.

#### Scenario: One-command dev runs both processes

- **WHEN** the developer runs the studio dev script
- **THEN** the daemon starts and reports healthy before Vite starts, the
app is served on the Vite port, and every `/rpc` and `/api` request is
answered by the daemon process

#### Scenario: Recipe-DAG mount loads natively under Bun

- **WHEN** the daemon serves a recipe-DAG RPC request
- **THEN** the handler is a statically imported module (no SSR loader
indirection) and the response matches the previous Vite-hosted mount

#### Scenario: Frontend-only Vite config

- **WHEN** the Vite config is evaluated (dev or build)
- **THEN** it imports no engine, direct-control, or server-handler modules
and registers no server middleware beyond the proxy

### Requirement: Studio Server State Lives In One Process

The Studio SHALL keep the serialized operation queue, the run-in-game and
save/deploy operation stores, and the server instance identity in exactly
one place — the daemon process — shared by every transport (oRPC mounts
and legacy compat); no studio server state may live in the Vite process. The engine
behaviors move verbatim: non-uniform error status codes are preserved
per-procedure, run-in-game status 404 echoes the server instance identity
while save/deploy status 404 does not, and `live/status` returns 200 with
per-field embedded errors.

#### Scenario: Both transports observe one queue

- **WHEN** a run-in-game operation is active and a save/deploy request
arrives on either the oRPC mount or the legacy compat surface
- **THEN** the request is rejected with the 409 dual-mutex semantics
naming the active operation

#### Scenario: Restart detection identity

- **WHEN** the daemon restarts and a client polls a stale run-in-game
request id
- **THEN** the 404 response carries the new process's `serverInstanceId`
and `serverStartedAt`

### Requirement: Legacy REST Surface Survives The Cutover Until The Retirement Checkpoint

The legacy `/api/*` REST handlers SHALL remain available through the
cutover as a daemon-hosted compat layer with response bodies and status
codes identical to the Vite-hosted handlers, and SHALL NOT be retired
without an explicit user-approved retirement decision recorded in this
workstream.

#### Scenario: Compat parity

- **WHEN** a legacy endpoint is requested after the cutover
- **THEN** the response status and body shape match the pre-cutover
Vite-hosted handler for the same engine state

#### Scenario: Retirement is gated

- **WHEN** the cutover slices land without a recorded user checkpoint
decision
- **THEN** the compat surface is still mounted and the retirement remains
an open follow-up in the workstream record
59 changes: 59 additions & 0 deletions openspec/changes/mapgen-studio-bun-server/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
## 1. Frame

- [ ] 1.1 Workstream record + proposal/design/tasks/spec deltas committed
(`design/bun-server-frame`), `--strict` valid.

## 2. Engine extraction (`design/bun-server-engines`) — no topology change

- [ ] 2.1 `src/server/studio/engines.ts`: `createStudioEngines({ repoRoot })`
— queue, both operation stores, instance identity, five engine fns
moved VERBATIM from `vite.config.ts`. No effect-orpc in its import
graph (config must stay node-evaluable).
- [ ] 2.2 `src/server/studio/context.ts`: `createStudioServerContext`
(moved `createStudioServerContextForApp`; non-uniform error mapping
intact).
- [ ] 2.3 `vite.config.ts` consumes the extracted modules at module scope;
legacy handlers and all three mounts behave identically.
- [ ] 2.4 Gates: tsc, studio tests, mod tests, build + worker bundle,
fresh-process live smoke (run loop + pipeline view + readiness poll).

## 3. Control-oRPC fetch adapter (`design/bun-control-fetch`)

- [ ] 3.1 `civ7ControlOrpc.ts` → canonical fetch handler
(`@orpc/server/fetch`) + Connect shim over `nodeWebBridge`,
mirroring `recipeDag/orpc.ts`; path contract unchanged.
- [ ] 3.2 Transport test (node:http + shim → fetch handler), mirroring the
recipe-DAG transport pins.
- [ ] 3.3 Gates: tsc, studio tests, live smoke (readiness/control poll).

## 4. Daemon + cutover (`design/bun-server-daemon`)

- [ ] 4.1 `src/server/daemon/daemon.ts`: arg parsing, one engines
instance, `createStudioDaemonFetch(deps)` (pure, testable) routing
`/healthz`, `/rpc`, `/api/civ7/rpc`, `/api/recipe-dag/rpc` (static
import), legacy compat, optional static assets; `Bun.serve` entry.
- [ ] 4.2 `src/server/studio/legacyHttp.ts`: 16 REST handlers as fetch
adapters over the shared engines — identical bodies/status codes
(404-echo asymmetry, non-uniform codes, live/status embedded-error
200).
- [ ] 4.3 `src/server/daemon/devLive.ts`: spawn daemon → wait `/healthz` →
spawn Vite; signal forwarding; `makeDevLivePlan` pure.
- [ ] 4.4 `vite.config.ts`: delete the server plugin + server imports; add
`server.proxy` for `/rpc` + `/api` (`STUDIO_DEV_RPC_TARGET`
override). `package.json`: `dev` → runner, `dev:frontend`,
`dev:server`.
- [ ] 4.5 Tests: daemon route dispatch; legacy compat parity pins;
dev-live plan; existing transport/operation pins green unchanged.
- [ ] 4.6 Gates: tsc, studio tests, mod tests, build + worker bundle.
- [ ] 4.7 Live verification on FRESH processes: `bun run dev` boots both;
generation run completes; pipeline view loads DAG via proxy; control
readiness poll works; curl pins (`/healthz`, one legacy path,
`studio.serverInfo`); dark theme visual unchanged.

## 5. Retirement checkpoint (SUPERVISED — not auto-run)

- [ ] 5.1 Present the retirement list (design.md table + consumer
evidence) to the user; record the decision in the workstream record.
- [ ] 5.2 (post-approval) `design/bun-legacy-retire`: delete
`legacyHttp.ts` + mount + compat tests; update
`scripts/civ7-direct-control/verify-final-surface-parity.ts`.
Loading