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
54 changes: 54 additions & 0 deletions apps/mapgen-studio/src/server/http/nodeWebBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { IncomingMessage, ServerResponse } from "node:http";

/**
* Node ⇄ Web request/response bridge — the ONE conversion shim between
* Connect-style hosts (the Vite dev middleware stack, bare `node:http` test
* servers) and the studio's fetch-adapter oRPC handlers
* (`RPCHandler` from `@orpc/server/fetch`, per the A4-lite seam: handlers are
* `Request`/`Response` so they drop verbatim onto a Bun server at the P5b
* cutover).
*
* Extracted from `vite.config.ts` (mapgen-studio-dag-tab mount re-home) so the
* `/rpc` studio-server mount, the recipe-dag mount, and the transport tests
* all share the same buffering and prefix-restoration behavior.
*/

/**
* Adapt a Vite/Connect Node request (`IncomingMessage`) to a Web `Request`.
* Runs before Vite consumes the body, so we buffer it here (oRPC reads the Web
* `Request` body itself). GET/HEAD carry no body. Host/proto come from headers
* (dev server is local).
*/
export async function nodeRequestToWebRequest(req: IncomingMessage): Promise<Request> {
const method = req.method ?? "GET";
const host = (req.headers.host as string | undefined) ?? "localhost";
// Connect's path-mounted middleware (`use("/rpc", …)`) STRIPS the mount prefix
// from `req.url`, but the oRPC handler matches against the full `/rpc/...` path
// (its `prefix`). Use `originalUrl` (the un-rewritten path) so the prefix matches.
const path = (req as { originalUrl?: string }).originalUrl ?? req.url ?? "/";
const url = `http://${host}${path}`;
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value === undefined) continue;
if (Array.isArray(value)) for (const v of value) headers.append(key, v);
else headers.set(key, value);
}
let body: Buffer | undefined;
if (method !== "GET" && method !== "HEAD") {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
body = Buffer.concat(chunks);
}
return new Request(url, {
method,
headers,
...(body && body.length > 0 ? { body, duplex: "half" } : {}),
} as RequestInit & { duplex?: "half" });
}

/** Write a Web `Response` back onto a Node `ServerResponse`. */
export async function writeWebResponse(res: ServerResponse, response: Response): Promise<void> {
res.statusCode = response.status;
response.headers.forEach((value, key) => res.setHeader(key, value));
res.end(response.body ? Buffer.from(await response.arrayBuffer()) : undefined);
}
71 changes: 64 additions & 7 deletions apps/mapgen-studio/src/server/recipeDag/orpc.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,35 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { RPCHandler } from "@orpc/server/node";
import { RPCHandler } from "@orpc/server/fetch";

import { STUDIO_RECIPE_DAG_ORPC_PATH } from "../../shared/recipeDagOrpc";
import { nodeRequestToWebRequest, writeWebResponse } from "../http/nodeWebBridge";
import type { RecipeDagService } from "./context";
import { defaultRecipeDagService } from "./service";
import { RecipeDagRouter } from "./router";

// ============================================================================
// Recipe-DAG oRPC edge — fetch adapter behind a Connect shim
// (mapgen-studio-dag-tab mount re-home; mirrors @civ7/studio-server's handler)
// ============================================================================
// The canonical artifact is the FETCH handler (`Request`/`Response`): it works
// under the Vite dev middleware today via the shared node⇄web bridge and drops
// verbatim onto a Bun server at the P5b cutover — the same A4-lite seam the
// studio-server `/rpc` mount rides.
//
// This module is loaded through Vite's SSR pipeline (PER-REQUEST
// `ssrLoadModule` in vite.config.ts), and that is load-bearing: the effect-orpc
// router layer below imports `effect-orpc`, whose package entry is TypeScript
// SOURCE — Node cannot load it outside a transform pipeline, so the module
// cannot be statically imported into the config. Per-request loading (vs the
// previous forever-memoized promise) means edits here are served on the next
// request; the recipe CONTRACTS still resolve to built dist (no source
// aliases; dist is watch-ignored), so contract edits need a rebuild + restart
// regardless of mount mechanism.
//
// The URL is a pinned contract (handoff §1: "keep the path contract"):
// `STUDIO_RECIPE_DAG_ORPC_PATH` — the client is untouched by the re-home.
// ============================================================================

export type StudioRecipeDagNext = (err?: unknown) => void;

export function createStudioRecipeDagContext(
Expand All @@ -18,20 +42,53 @@ export function createStudioRecipeDagContext(
};
}

export interface StudioRecipeDagRpcHandle {
handle(request: Request): Promise<{ matched: boolean; response?: Response }>;
}

/** The transport-portable handler (Bun-ready; the Vite mount is a thin shim). */
export function createStudioRecipeDagRpcHandler(
options: Readonly<{
recipeDagService?: RecipeDagService;
}> = {},
): StudioRecipeDagRpcHandle {
const handler = new RPCHandler(RecipeDagRouter);
const context = createStudioRecipeDagContext(options);
return {
handle: (request) =>
handler.handle(request, {
prefix: STUDIO_RECIPE_DAG_ORPC_PATH,
context,
}),
};
}

/**
* Connect-style adapter over the fetch handler, for the Vite dev middleware
* stack and the `node:http` transport tests. The cheap path pre-check keeps
* the body-buffering bridge off every other request when mounted unscoped.
*/
export function createStudioRecipeDagOrpcMiddleware(
options: Readonly<{
recipeDagService?: RecipeDagService;
}> = {},
): (req: IncomingMessage, res: ServerResponse, next: StudioRecipeDagNext) => Promise<void> {
const rpcHandler = new RPCHandler(RecipeDagRouter);
const rpcHandler = createStudioRecipeDagRpcHandler(options);

return async (req, res, next) => {
try {
const result = await rpcHandler.handle(req, res, {
prefix: STUDIO_RECIPE_DAG_ORPC_PATH,
context: createStudioRecipeDagContext(options),
});
if (!result.matched) next();
const path = (req as { originalUrl?: string }).originalUrl ?? req.url ?? "/";
if (!path.startsWith(STUDIO_RECIPE_DAG_ORPC_PATH)) {
next();
return;
}
const request = await nodeRequestToWebRequest(req);
const { matched, response } = await rpcHandler.handle(request);
if (!matched || !response) {
next();
return;
}
await writeWebResponse(res, response);
} catch (err) {
next(err);
}
Expand Down
71 changes: 22 additions & 49 deletions apps/mapgen-studio/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ import { parseMapConfigSaveRequest } from "./src/server/mapConfigs/requestValida
import type { RunInGamePhase, RunInGameRequestStatus } from "./src/features/runInGame/status";
import { buildLiveRuntimeStatusState } from "./src/features/liveRuntime/model";
import { handleStudioCiv7ControlOrpcRequest } from "./src/server/civ7ControlOrpc";
import { STUDIO_RECIPE_DAG_ORPC_PATH } from "./src/shared/recipeDagOrpc";
import { nodeRequestToWebRequest, writeWebResponse } from "./src/server/http/nodeWebBridge";
import {
createStudioRpcHandler,
orpcError,
Expand Down Expand Up @@ -359,38 +361,9 @@ async function materializeRunInGameConfig(args: {
};
}

/**
* Adapt a Vite/Connect Node request (`IncomingMessage`) to a Web `Request` for the
* oRPC fetch-adapter `RPCHandler`. The `/rpc` middleware runs before Vite consumes
* the body, so we buffer it here (oRPC reads the Web `Request` body itself). GET/
* HEAD carry no body. Host/proto come from headers (dev server is local).
*/
async function nodeRequestToWebRequest(req: import("node:http").IncomingMessage): Promise<Request> {
const method = req.method ?? "GET";
const host = (req.headers.host as string | undefined) ?? "localhost";
// Connect's path-mounted middleware (`use("/rpc", …)`) STRIPS the mount prefix
// from `req.url`, but the oRPC handler matches against the full `/rpc/...` path
// (its `prefix`). Use `originalUrl` (the un-rewritten path) so the prefix matches.
const path = (req as { originalUrl?: string }).originalUrl ?? req.url ?? "/";
const url = `http://${host}${path}`;
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value === undefined) continue;
if (Array.isArray(value)) for (const v of value) headers.append(key, v);
else headers.set(key, value);
}
let body: Buffer | undefined;
if (method !== "GET" && method !== "HEAD") {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
body = Buffer.concat(chunks);
}
return new Request(url, {
method,
headers,
...(body && body.length > 0 ? { body, duplex: "half" } : {}),
} as RequestInit & { duplex?: "half" });
}
// `nodeRequestToWebRequest` / `writeWebResponse` (the Node ⇄ Web bridge shared
// by the fetch-adapter oRPC mounts) moved to ./src/server/http/nodeWebBridge —
// statically imported above like the other server modules.

function writeJson(res: { statusCode: number; setHeader(name: string, value: string): void; end(body?: string): void }, statusCode: number, body: unknown): void {
res.statusCode = statusCode;
Expand Down Expand Up @@ -1078,22 +1051,24 @@ export default defineConfig(({ command }) => ({
{
name: "repo-backed-map-configs",
configureServer(server) {
let recipeDagOrpcMiddlewarePromise:
| Promise<typeof import("./src/server/recipeDag/orpc").handleStudioRecipeDagOrpcRequest>
| null = null;
const loadRecipeDagOrpcMiddleware = () => {
recipeDagOrpcMiddlewarePromise ??= server
.ssrLoadModule("/src/server/recipeDag/orpc.ts")
.then((module) => module.handleStudioRecipeDagOrpcRequest);
return recipeDagOrpcMiddlewarePromise;
};

server.middlewares.use(handleStudioCiv7ControlOrpcRequest);
// Recipe-DAG oRPC mount. The handler module MUST load through Vite's
// SSR pipeline: its effect-orpc router layer imports `effect-orpc`,
// whose package entry is TypeScript SOURCE — Node cannot type-strip
// under node_modules, so a static import here breaks config evaluation
// (@civ7/studio-server avoids this only by bundling effect-orpc into
// its dist). `ssrLoadModule` is called PER REQUEST — Vite's documented
// SSR pattern: unchanged graphs are cheap cache hits, and edits to the
// server module are picked up on the next request (the previous
// forever-memoized promise served the first load until restart). The
// path pre-check keeps the SSR loader off every other request.
server.middlewares.use((req, res, next) => {
void loadRecipeDagOrpcMiddleware().then(
(middleware) => {
void middleware(req, res, next);
},
const path = (req as { originalUrl?: string }).originalUrl ?? req.url ?? "/";
if (!path.startsWith(STUDIO_RECIPE_DAG_ORPC_PATH)) return next();
server.ssrLoadModule("/src/server/recipeDag/orpc.ts").then(
(module) =>
void (module as typeof import("./src/server/recipeDag/orpc"))
.handleStudioRecipeDagOrpcRequest(req, res, next),
next,
);
});
Expand Down Expand Up @@ -1402,9 +1377,7 @@ export default defineConfig(({ command }) => ({
next();
return;
}
res.statusCode = response.status;
response.headers.forEach((value, key) => res.setHeader(key, value));
res.end(response.body ? Buffer.from(await response.arrayBuffer()) : undefined);
await writeWebResponse(res, response);
});
},
},
Expand Down
46 changes: 46 additions & 0 deletions openspec/changes/mapgen-studio-dag-tab/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,49 @@ DAG moves over unchanged.
deletion, gates green, visual verification dark + light.
3. Ledger/system.md amendments ride slice 2 (decision records are part of
the change).
4. `design/dag-orpc-mount` — mount re-home (user-directed; researched via a
4-agent workflow before deciding).

## Addendum — the mount re-home decision (slice 4)

The user asked whether vite middleware is really the right place for the
recipe-dag oRPC mount. Research verdict (repo inspection × oRPC docs ×
Vite docs × effect-orpc README):

- **Vite IS the right host today** — it is the only dev server, and
production is a static Caddy/dist SPA with no server process at all, so
dev semantics are the only semantics. oRPC documents no Vite
integration; `configureServer` middleware is Vite's canonical mechanism.
effect-orpc prescribes no transport (it hands back a plain oRPC router).
- **But the merged mount's mechanism had a real defect.** Its handler
promise was memoized forever (`??=`, never reset), so even app-src edits
to the server module were never re-served; and its supposed
contract-freshness was void anyway — the recipe contracts resolve to
BUILT dist via package exports (no source aliases) and
`server.watch.ignored` excludes all dist.
- **A static config import is NOT available as the fix** (first attempt,
corrected): the router layer imports `effect-orpc`, whose package entry
is TypeScript SOURCE — Node cannot type-strip under node_modules, so a
static import breaks config evaluation (`vite build` fails; the dev
server's restart-on-config-change fails and silently keeps serving the
OLD config — which is also why the first smoke test lied).
`@civ7/studio-server` tolerates static import only because tsup bundles
effect-orpc into its dist.
- **Resolution:** the canonical artifact is now the FETCH-adapter handler
(`createStudioRecipeDagRpcHandler`, mirroring `@civ7/studio-server`'s
A4-lite shape — drops verbatim onto Bun at the P5b cutover, per oRPC's
own Bun guidance), behind a thin Connect shim. The mount keeps
`ssrLoadModule` (the only loader that compiles effect-orpc's TS under
Node) but calls it PER REQUEST — Vite's documented SSR pattern: cheap
cache hits when unchanged, fresh code on the next request after an edit
(fixing the memoize-stale defect) — behind a path pre-check so the SSR
loader never runs for unrelated requests. The node⇄web bridge moved out
of vite.config into `src/server/http/nodeWebBridge.ts`, shared by the
`/rpc` and recipe-dag mounts. Path contract `/api/recipe-dag/rpc`
unchanged (handoff §1).
- **Not done, deliberately:** folding the router into `@civ7/studio-server`
— its dependency graph is deliberately mod-agnostic (the recipe-dag
service imports mod-swooper-maps), and its contracts are zod where
recipe-dag's are TypeBox. The documented-legitimate shape is the sibling
handler at its own prefix; full composition is a P5b-era question with
the service host-injected via context if ever wanted.
19 changes: 19 additions & 0 deletions openspec/changes/mapgen-studio-dag-tab/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,22 @@
view returns with camera intact; run loop works while pipeline view
is active.
- [x] 3.4 Workstream/phase record closed with evidence.

## 4. Mount re-home (user-directed follow-up)

- [x] 4.1 Research pass (4-agent workflow): repo resolution/freshness facts,
lane server-seam target, oRPC adapter docs, effect-orpc + Vite docs.
- [x] 4.2 `server/recipeDag/orpc.ts` → fetch adapter (`@orpc/server/fetch`),
mirroring studio-server's handler shape; Connect shim kept for the
Vite mount + transport tests; path contract unchanged.
- [x] 4.3 Extract the node⇄web bridge to `server/http/nodeWebBridge.ts`;
both the `/rpc` mount and the recipe-dag mount share it.
- [x] 4.4 `vite.config.ts`: forever-memoized lazy mount → PER-REQUEST
`ssrLoadModule` behind a path pre-check. (A static import was tried
first and reverted: effect-orpc ships TS source, which Node cannot
load outside Vite's pipeline — `vite build` failed and the dev
server's config restart failed silently.)
- [x] 4.5 Gates: tsc; 193 tests (orpc.test.ts pins unchanged over the
fetch-backed middleware); `vite build` evaluates the config; clean
dev-server start + live smoke — `POST
/api/recipe-dag/rpc/recipeDag/get → 200`, pipeline renders.