# Monolith and Split Deployment Architecture Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **Status note:** This is a historical proposal, not the current architecture source of truth. Before executing any task from this plan, verify paths and route ownership against `AGENTS.md`, `docs/contributing/architecture.mdx`, and the current `apps/server/src/{http,rpc,mcp,openapi,static,startup}` tree. **Goal:** Preserve the existing one-image Docker contract for open-source self-hosters while enabling the same codebase to run as an edge/static frontend plus Node backend for hosted and VPS split deployments. **Architecture:** Reactive Resume should have one route ownership model and multiple deployment compositions. The OSS Docker image remains a monolith: one image, one port, backend routes plus static SPA fallback. Hosted/split deployments serve the built SPA statically from Cloudflare/S3/NGINX/Caddy and route only backend-owned paths to the Node server, where unknown frontend routes return `404`. **Tech Stack:** Hono, Better Auth, Vite, Turborepo, Docker, optional NGINX/Caddy/CDN routing, Zod JSON Schema generation. --- ## Decisions Locked By This Plan - Keep the official Docker image as a complete monolith. - Do not make NGINX a required wrapper for the official OSS image. - Use Hono static serving as the Docker/self-host fallback. - Prefer edge/static frontend plus Node backend for hosted Reactive Resume. - Do not pursue Cloudflare Worker backend support in this phase. - Keep backend logic Node-first and platform-independent where practical, but do not contort PDF, database, storage, or auth runtime code for Worker compatibility yet. - Move custom OAuth bridge behavior under `/api/auth/oauth`. - Keep `/.well-known/*` backend-owned because OAuth/MCP metadata is runtime-owned. - Make `/schema.json` frontend/static-owned by generating it during build/source preparation. - In split backend mode, unknown non-backend routes return `404`. ## Deployment Modes ### Mode 1: Docker Monolith **Audience:** OSS users and current Docker image consumers. ```txt Request -> Node/Hono server /api/* -> backend /api/auth/oauth -> custom OAuth bridge /mcp, /mcp/* -> backend /.well-known/* -> backend /uploads/* -> backend unless object storage owns this later /schema.json -> static file from apps/web/dist static assets -> static file from apps/web/dist non-file GET/HEAD -> apps/web/dist/index.html file-looking miss -> 404 ``` ### Mode 2: Split VPS **Audience:** Advanced self-hosters or the project owner's VPS migration path. ```txt Request -> NGINX/Caddy static files -> apps/web/dist SPA fallback -> apps/web/dist/index.html backend routes -> proxy to Node Request -> Node/Hono backend-only app backend routes -> backend unknown routes -> 404 ``` ### Mode 3: Hosted Edge Frontend + Node Backend **Audience:** Cloud hosted Reactive Resume. ```txt Request -> Cloudflare/S3/CDN static files -> edge object/static hosting SPA fallback -> edge index.html backend routes -> proxy/fetch to Node backend Request -> Node backend backend routes -> backend unknown routes -> 404 ``` --- ## Route Ownership Contract ### Backend-Owned Routes ```txt /api/* /api/auth/oauth /mcp /mcp/* /.well-known/* /uploads/* ``` `/uploads/*` remains backend-owned while local filesystem uploads are supported. If uploads move fully to S3/R2 public or signed URLs later, this can become object-storage-owned. ### Frontend/Static-Owned Routes ```txt /schema.json /assets/* /templates/* /favicon.ico /favicon.svg /manifest.webmanifest /robots.txt /sitemap.xml /fonts/* /icon/* /logo/* /opengraph/* /photos/* /pwa-*.png /sounds/* /videos/* ``` ### Frontend SPA-Owned Routes ```txt /auth/* /dashboard/* /builder/* /agent/* /$username/$slug all other non-file GET/HEAD frontend routes ``` In Docker monolith mode, Node serves the SPA fallback. In split modes, the static frontend host serves the SPA fallback and Node returns `404` for unknown non-backend routes. --- ## File Structure - Create `packages/server` - Owns route ownership constants and shared Hono app composition. - Exposes `createBackendApp()` and `createMonolithApp()`. - Modify `apps/server/src/index.ts` - Becomes a thin Node entrypoint around `@reactive-resume/server/node`. - Selects monolith/backend-only mode through explicit env/config. - Modify `apps/server/src/handlers/*` - Move or re-export handlers into `packages/server` only after package boundaries are clear. - Keep this as a later task if direct movement is too large. - Modify `packages/auth/src/config.ts` - Change OAuth provider `loginPage` and `consentPage` to `/api/auth/oauth`. - Modify `apps/server/src/handlers/auth.ts` - Change callback preservation to `/api/auth/oauth`. - Create `packages/scripts/schema/generate.ts` - Generates both package and web-public schema artifacts. - Create `apps/web/public/schema.json` - Static schema artifact for Vite, Docker monolith, S3, and edge hosting. - Modify `apps/web/package.json` - Runs schema generation before build. - Modify `apps/web/vite.config.ts` - Remove dev proxies that become unnecessary after route/static ownership cleanup. - Modify `Dockerfile` - Preserve one-image behavior. - Make runtime default to monolith mode. - Create `docs/deployment/architecture.mdx` - Explains monolith vs split modes. - Create `docs/deployment/split-vps.mdx` - Shows NGINX/Caddy examples. - Create `docs/deployment/edge-frontend-node-backend.mdx` - Shows Cloudflare/S3/CDN static frontend plus Node backend. - Create or update `AGENTS.md` - Records route ownership and deployment-mode rules as normative architecture guidance. --- ## Task 1: Document The Deployment Contract First **Files:** - Create: `docs/deployment/architecture.mdx` - Create: `docs/deployment/split-vps.mdx` - Create: `docs/deployment/edge-frontend-node-backend.mdx` - Modify: `AGENTS.md` - [ ] **Step 1: Write `docs/deployment/architecture.mdx`** Create the document with these sections: ```mdx # Deployment Architecture Reactive Resume supports multiple deployment compositions from one codebase. ## Default: Docker Monolith The official Docker image remains a complete application image. It exposes one port and serves backend routes, static assets, and SPA fallback from the same Node process. This is the compatibility contract for open-source self-hosters. ## Split Frontend And Backend Split deployments serve `apps/web/dist` from a static host and route backend-owned paths to the Node server. The Node server does not serve SPA fallback in backend-only mode. Unknown non-backend routes return `404`. ## Route Ownership Backend-owned: - `/api/*` - `/api/auth/oauth` - `/mcp` - `/mcp/*` - `/.well-known/*` - `/uploads/*` Frontend/static-owned: - `/schema.json` - `/assets/*` - `/templates/*` - `/favicon.ico` - `/favicon.svg` - `/manifest.webmanifest` - `/robots.txt` - `/sitemap.xml` - `/fonts/*` - `/icon/*` - `/logo/*` - `/opengraph/*` - `/photos/*` - `/pwa-*.png` - `/sounds/*` - `/videos/*` Frontend SPA-owned: - `/auth/*` - `/dashboard/*` - `/builder/*` - `/agent/*` - public resume pages - all other non-file frontend routes ## Runtime Modes - `monolith`: backend routes plus static SPA serving. - `backend-only`: backend routes only; unknown routes return `404`. ``` - [ ] **Step 2: Write `docs/deployment/split-vps.mdx`** Include this minimal NGINX shape: ```nginx server { listen 80; server_name example.com; root /srv/reactive-resume/web; index index.html; location ^~ /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location = /mcp { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location ^~ /mcp/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location ^~ /.well-known/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location ^~ /uploads/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } } ``` Add a note that `APP_URL` must be the public origin. - [ ] **Step 3: Write `docs/deployment/edge-frontend-node-backend.mdx`** Include: ```mdx # Edge Frontend With Node Backend Build `apps/web/dist` and upload it to the static host. Route backend-owned paths to the Node backend. Backend route patterns: - `/api/*` - `/mcp` - `/mcp/*` - `/.well-known/*` - `/uploads/*` The frontend host owns `/schema.json` and SPA fallback. The Node backend should run in backend-only mode so unknown frontend routes return `404`. ``` - [ ] **Step 4: Update `AGENTS.md`** Add a short normative section: ```md ### Deployment route ownership The official Docker image is a monolith and must keep serving the full application from one image and one port. Split deployments are supported by serving `apps/web/dist` statically and routing backend-owned paths to Node. In backend-only mode, Node returns `404` for unknown frontend routes. Do not add new backend routes outside `/api/*`, `/mcp`, `/.well-known/*`, or explicitly documented exceptions without updating the route ownership docs. ``` - [ ] **Step 5: Commit** ```bash git add docs/deployment/architecture.mdx docs/deployment/split-vps.mdx docs/deployment/edge-frontend-node-backend.mdx AGENTS.md git commit -m "docs: define monolith and split deployment contract" ``` --- ## Task 2: Create Route Ownership Primitives **Files:** - Create: `packages/server/package.json` - Create: `packages/server/tsconfig.json` - Create: `packages/server/vitest.config.ts` - Create: `packages/server/src/routes/ownership.ts` - Create: `packages/server/src/routes/ownership.test.ts` - [ ] **Step 1: Write the failing ownership tests** Create `packages/server/src/routes/ownership.test.ts`: ```ts import { describe, expect, it } from "vitest"; import { getRouteOwner } from "./ownership"; describe("getRouteOwner", () => { it.each([ ["/api/rpc", "backend"], ["/api/auth/oauth", "backend"], ["/mcp", "backend"], ["/mcp/tools", "backend"], ["/.well-known/oauth-protected-resource", "backend"], ["/uploads/avatar.png", "backend"], ["/schema.json", "static"], ["/assets/app.js", "static"], ["/templates/jpg/azurill.jpg", "static"], ["/auth/login", "spa"], ["/dashboard", "spa"], ["/builder/abc", "spa"], ["/agent/thread", "spa"], ["/amruth/resume", "spa"], ])("classifies %s as %s", (pathname, owner) => { expect(getRouteOwner(pathname)).toBe(owner); }); it.each(["/missing.js", "/unknown.css", "/image.png"])("classifies file-looking misses as static", (pathname) => { expect(getRouteOwner(pathname)).toBe("static"); }); }); ``` - [ ] **Step 2: Add package scaffolding** Create `packages/server/package.json`: ```json { "name": "@reactive-resume/server", "version": "0.0.0", "type": "module", "private": true, "exports": { "./routes/ownership": "./src/routes/ownership.ts" }, "scripts": { "typecheck": "tsgo --noEmit", "test": "vitest run --passWithNoTests" }, "devDependencies": { "@reactive-resume/config": "workspace:*", "@typescript/native-preview": "7.0.0-dev.20260514.1", "typescript": "^6.0.3" } } ``` Create `packages/server/tsconfig.json`: ```json { "extends": "@reactive-resume/config/tsconfig.base.json" } ``` Create `packages/server/vitest.config.ts`: ```ts import { createVitestProjectConfig } from "@reactive-resume/config/vitest"; export default createVitestProjectConfig({ name: "@reactive-resume/server", }); ``` - [ ] **Step 3: Run the failing tests** ```bash pnpm --filter @reactive-resume/server test -- src/routes/ownership.test.ts ``` Expected: fails because `ownership.ts` does not exist. - [ ] **Step 4: Implement route ownership** Create `packages/server/src/routes/ownership.ts`: ```ts export type RouteOwner = "backend" | "static" | "spa"; const backendExactRoutes = new Set(["/mcp"]); const backendPrefixes = ["/api/", "/mcp/", "/.well-known/", "/uploads/"]; const staticExactRoutes = new Set([ "/schema.json", "/favicon.ico", "/favicon.svg", "/manifest.webmanifest", "/robots.txt", "/sitemap.xml", ]); const staticPrefixes = [ "/assets/", "/templates/", "/fonts/", "/icon/", "/logo/", "/opengraph/", "/photos/", "/sounds/", "/videos/", ]; function looksLikeFile(pathname: string) { const lastSegment = pathname.split("/").pop() ?? ""; return lastSegment.includes("."); } export function getRouteOwner(pathname: string): RouteOwner { if (backendExactRoutes.has(pathname)) return "backend"; if (backendPrefixes.some((prefix) => pathname.startsWith(prefix))) return "backend"; if (staticExactRoutes.has(pathname)) return "static"; if (staticPrefixes.some((prefix) => pathname.startsWith(prefix))) return "static"; if (/^\/pwa-\d+x\d+\.png$/.test(pathname)) return "static"; if (looksLikeFile(pathname)) return "static"; return "spa"; } export function isBackendRoute(pathname: string) { return getRouteOwner(pathname) === "backend"; } export function isStaticRoute(pathname: string) { return getRouteOwner(pathname) === "static"; } ``` - [ ] **Step 5: Verify route ownership tests** ```bash pnpm --filter @reactive-resume/server test -- src/routes/ownership.test.ts pnpm --filter @reactive-resume/server typecheck ``` Expected: both pass. - [ ] **Step 6: Commit** ```bash git add packages/server git commit -m "feat(server): add route ownership contract" ``` --- ## Task 3: Move Custom OAuth Bridge To `/api/auth/oauth` **Files:** - Modify: `apps/server/src/handlers/auth.test.ts` - Create: `apps/server/src/index.test.ts` - Modify: `apps/server/src/index.ts` - Modify: `apps/server/src/handlers/auth.ts` - Modify: `packages/auth/src/config.ts` - Modify: `apps/web/vite.config.ts` - [ ] **Step 1: Update the handler test first** In `apps/server/src/handlers/auth.test.ts`, change the request URL and expected callback path: ```ts const response = await handleOAuth( new Request( "http://localhost:3001/api/auth/oauth?client_id=test-client&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=abc&exp=123&sig=456", ), ); expect(callbackUrl.pathname).toBe("/api/auth/oauth"); ``` - [ ] **Step 2: Add a route precedence test** Create `apps/server/src/index.test.ts`: ```ts import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ handleAuth: vi.fn(), handleOAuth: vi.fn(), })); vi.mock("./handlers/auth", () => ({ handleAuth: mocks.handleAuth, handleOAuth: mocks.handleOAuth, })); vi.mock("./handlers/health", () => ({ handleHealth: () => Response.json({ ok: true }) })); vi.mock("./handlers/mcp", () => ({ handleMcp: () => new Response("mcp") })); vi.mock("./handlers/metadata", () => ({ handleMcpServerCard: () => Response.json({}), handleOAuthAuthorizationServer: () => Response.json({}), handleOAuthProtectedResource: () => Response.json({}), handleOpenIdConfiguration: () => Response.json({}), handleWellKnownFallback: () => new Response("OK"), })); vi.mock("./handlers/openapi", () => ({ handleOpenApi: () => Response.json({}) })); vi.mock("./handlers/rpc", () => ({ handleRpc: () => Response.json({}) })); vi.mock("./handlers/uploads", () => ({ handleUpload: () => new Response("upload") })); describe("createApp route ownership", () => { beforeEach(() => { mocks.handleAuth.mockReset(); mocks.handleOAuth.mockReset(); mocks.handleAuth.mockResolvedValue(Response.json({ route: "auth" })); mocks.handleOAuth.mockResolvedValue(Response.json({ route: "oauth" })); }); it("routes /api/auth/oauth to the custom OAuth bridge before the Better Auth catch-all", async () => { const { createApp } = await import("./index"); const response = await createApp().request("http://localhost:3001/api/auth/oauth?client_id=test"); await expect(response.json()).resolves.toEqual({ route: "oauth" }); expect(mocks.handleOAuth).toHaveBeenCalledTimes(1); expect(mocks.handleAuth).not.toHaveBeenCalled(); }); it("keeps other /api/auth/* requests on the Better Auth handler", async () => { const { createApp } = await import("./index"); const response = await createApp().request("http://localhost:3001/api/auth/session"); await expect(response.json()).resolves.toEqual({ route: "auth" }); expect(mocks.handleAuth).toHaveBeenCalledTimes(1); expect(mocks.handleOAuth).not.toHaveBeenCalled(); }); }); ``` - [ ] **Step 3: Run failing tests** ```bash pnpm --filter server test -- src/handlers/auth.test.ts src/index.test.ts ``` Expected: tests fail because the implementation still uses `/auth/oauth`. - [ ] **Step 4: Update server route order** In `apps/server/src/index.ts`, register the custom route before Better Auth catch-all: ```ts app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw)); app.on(["GET", "POST"], "/api/auth/*", (c) => handleAuth(c.req.raw)); ``` Remove: ```ts app.get("/auth/oauth", (c) => handleOAuth(c.req.raw)); ``` - [ ] **Step 5: Update callback preservation** In `apps/server/src/handlers/auth.ts`, change: ```ts loginUrl.searchParams.set("callbackURL", `/auth/oauth?${oauthParams.toString()}`); ``` to: ```ts loginUrl.searchParams.set("callbackURL", `/api/auth/oauth?${oauthParams.toString()}`); ``` - [ ] **Step 6: Update Better Auth OAuth provider pages** In `packages/auth/src/config.ts`, change: ```ts oauthProvider({ loginPage: "/api/auth/oauth", consentPage: "/api/auth/oauth", ``` - [ ] **Step 7: Remove old dev proxy** In `apps/web/vite.config.ts`, remove the `"/auth/oauth"` proxy entry. `/api/auth/oauth` is covered by the existing `"/api"` proxy. - [ ] **Step 8: Verify and commit** ```bash pnpm --filter server test -- src/handlers/auth.test.ts src/index.test.ts pnpm --filter server typecheck pnpm --filter @reactive-resume/auth typecheck ``` Expected: all pass. ```bash git add apps/server/src/handlers/auth.test.ts apps/server/src/index.test.ts apps/server/src/index.ts apps/server/src/handlers/auth.ts packages/auth/src/config.ts apps/web/vite.config.ts git commit -m "fix(auth): move OAuth bridge under api auth routes" ``` --- ## Task 4: Make `/schema.json` Static **Files:** - Create: `packages/scripts/schema/generate.ts` - Create: `packages/scripts/schema/generate.test.ts` - Modify: `packages/scripts/package.json` - Modify: `apps/web/package.json` - Create: `apps/web/public/schema.json` - Modify: `apps/web/vite.config.ts` - Modify: `apps/server/src/index.ts` - Modify: `apps/server/src/handlers/metadata.ts` - [ ] **Step 1: Write generator test first** Create `packages/scripts/schema/generate.test.ts`: ```ts import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { generateResumeSchemaJson } from "./generate"; describe("generateResumeSchemaJson", () => { it("writes identical resume JSON Schema to every target", async () => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "resume-schema-")); const firstTarget = path.join(directory, "schema-a.json"); const secondTarget = path.join(directory, "nested", "schema-b.json"); await generateResumeSchemaJson([firstTarget, secondTarget]); const first = await fs.readFile(firstTarget, "utf-8"); const second = await fs.readFile(secondTarget, "utf-8"); expect(JSON.parse(first)).toEqual(JSON.parse(second)); expect(JSON.parse(first)).toHaveProperty("properties.basics"); expect(first.endsWith("\n")).toBe(true); }); }); ``` - [ ] **Step 2: Add script package entries** In `packages/scripts/package.json`, add: ```json "schema:generate": "tsx schema/generate.ts", "test": "vitest run --passWithNoTests" ``` Add missing dev dependencies: ```json "@reactive-resume/schema": "workspace:*", "vitest": "^4.1.6", "zod": "^4.4.3" ``` - [ ] **Step 3: Run failing generator test** ```bash pnpm --filter @reactive-resume/scripts test -- schema/generate.test.ts ``` Expected: fails because `generate.ts` does not exist. - [ ] **Step 4: Implement generator** Create `packages/scripts/schema/generate.ts`: ```ts import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import z from "zod"; import { resumeDataSchema } from "@reactive-resume/schema/resume/data"; const defaultTargets = [ new URL("../../schema/schema.json", import.meta.url), new URL("../../../apps/web/public/schema.json", import.meta.url), ]; function toPath(target: string | URL) { return target instanceof URL ? fileURLToPath(target) : target; } export async function generateResumeSchemaJson(targets: Array = defaultTargets) { const schema = z.toJSONSchema(resumeDataSchema); const contents = `${JSON.stringify(schema, null, "\t")}\n`; await Promise.all( targets.map(async (target) => { const targetPath = toPath(target); await fs.mkdir(path.dirname(targetPath), { recursive: true }); await fs.writeFile(targetPath, contents); }), ); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { await generateResumeSchemaJson(); } ``` - [ ] **Step 5: Wire generation into web build** In `apps/web/package.json`, add: ```json "prebuild": "pnpm --filter @reactive-resume/scripts schema:generate" ``` Keep: ```json "build": "rm -rf dist && vite build" ``` - [ ] **Step 6: Remove backend runtime schema route** In `apps/server/src/index.ts`, remove the `handleSchemaJson` import and: ```ts app.get("/schema.json", () => handleSchemaJson()); ``` In `apps/server/src/handlers/metadata.ts`, remove: ```ts import z from "zod"; import { resumeDataSchema } from "@reactive-resume/schema/resume/data"; ``` and delete `handleSchemaJson()`. - [ ] **Step 7: Remove web dev proxy for schema** In `apps/web/vite.config.ts`, remove the `"/schema.json"` proxy entry. - [ ] **Step 8: Generate and verify** ```bash pnpm --filter @reactive-resume/scripts schema:generate pnpm --filter @reactive-resume/scripts test -- schema/generate.test.ts pnpm --filter @reactive-resume/scripts typecheck pnpm --filter server typecheck pnpm --filter web build test -f apps/web/dist/schema.json cmp packages/schema/schema.json apps/web/public/schema.json ``` Expected: all commands pass. - [ ] **Step 9: Commit** ```bash git add packages/scripts/schema packages/scripts/package.json apps/web/package.json apps/web/public/schema.json apps/web/vite.config.ts apps/server/src/index.ts apps/server/src/handlers/metadata.ts packages/schema/schema.json git commit -m "feat(web): serve resume schema as static asset" ``` --- ## Task 5: Split Server App Composition Into Backend-Only And Monolith **Files:** - Create: `apps/server/src/app.ts` - Create: `apps/server/src/app.test.ts` - Modify: `apps/server/src/index.ts` - Modify: `apps/server/package.json` - [ ] **Step 1: Add tests for deployment mode behavior** Create `apps/server/src/app.test.ts`: ```ts import { describe, expect, it, vi } from "vitest"; vi.mock("./handlers/auth", () => ({ handleAuth: () => Response.json({ route: "auth" }), handleOAuth: () => Response.json({ route: "oauth" }), })); vi.mock("./handlers/health", () => ({ handleHealth: () => Response.json({ ok: true }) })); vi.mock("./handlers/mcp", () => ({ handleMcp: () => new Response("mcp") })); vi.mock("./handlers/metadata", () => ({ handleMcpServerCard: () => Response.json({}), handleOAuthAuthorizationServer: () => Response.json({}), handleOAuthProtectedResource: () => Response.json({}), handleOpenIdConfiguration: () => Response.json({}), handleWellKnownFallback: () => new Response("OK"), })); vi.mock("./handlers/openapi", () => ({ handleOpenApi: () => Response.json({}) })); vi.mock("./handlers/rpc", () => ({ handleRpc: () => Response.json({}) })); vi.mock("./handlers/uploads", () => ({ handleUpload: () => new Response("upload") })); describe("server app deployment modes", () => { it("returns 404 for frontend routes in backend-only mode", async () => { const { createBackendApp } = await import("./app"); const response = await createBackendApp().request("http://localhost:3001/dashboard"); expect(response.status).toBe(404); }); it("keeps backend routes available in backend-only mode", async () => { const { createBackendApp } = await import("./app"); const response = await createBackendApp().request("http://localhost:3001/api/auth/oauth"); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ route: "oauth" }); }); }); ``` - [ ] **Step 2: Run failing tests** ```bash pnpm --filter server test -- src/app.test.ts ``` Expected: fails because `app.ts` does not exist. - [ ] **Step 3: Extract backend app composition** Create `apps/server/src/app.ts`: ```ts import fs from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { serveStatic } from "@hono/node-server/serve-static"; import { Hono } from "hono"; import { handleAuth, handleOAuth } from "./handlers/auth"; import { handleHealth } from "./handlers/health"; import { handleMcp } from "./handlers/mcp"; import { handleMcpServerCard, handleOAuthAuthorizationServer, handleOAuthProtectedResource, handleOpenIdConfiguration, handleWellKnownFallback, } from "./handlers/metadata"; import { handleOpenApi } from "./handlers/openapi"; import { handleRpc } from "./handlers/rpc"; import { handleUpload } from "./handlers/uploads"; const staticRoot = fileURLToPath(new URL("../../web/dist", import.meta.url)); const indexHtmlPath = fileURLToPath(new URL("../../web/dist/index.html", import.meta.url)); function registerBackendRoutes(app: Hono) { app.all("/api/rpc", (c) => handleRpc(c.req.raw)); app.all("/api/rpc/*", (c) => handleRpc(c.req.raw)); app.all("/api/openapi", (c) => handleOpenApi(c.req.raw)); app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw)); app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw)); app.on(["GET", "POST"], "/api/auth/*", (c) => handleAuth(c.req.raw)); app.get("/api/health", () => handleHealth()); app.get("/api/uploads/*", (c) => handleUpload(c.req.raw)); app.get("/uploads/*", (c) => handleUpload(c.req.raw)); app.all("/mcp", (c) => handleMcp(c.req.raw)); app.all("/mcp/*", (c) => handleMcp(c.req.raw)); app.get("/.well-known/mcp/server-card.json", () => handleMcpServerCard()); app.get("/.well-known/oauth-authorization-server", (c) => handleOAuthAuthorizationServer(c.req.raw)); app.get("/.well-known/oauth-authorization-server/*", (c) => handleOAuthAuthorizationServer(c.req.raw)); app.get("/.well-known/openid-configuration", (c) => handleOpenIdConfiguration(c.req.raw)); app.get("/.well-known/oauth-protected-resource", () => handleOAuthProtectedResource()); app.get("/.well-known/oauth-protected-resource/*", () => handleOAuthProtectedResource()); app.get("/.well-known/*", () => handleWellKnownFallback()); app.on(["HEAD"], "/.well-known/*", () => handleWellKnownFallback()); } export function createBackendApp() { const app = new Hono(); registerBackendRoutes(app); app.all("/*", () => new Response("Not Found", { status: 404 })); return app; } export function createMonolithApp() { const app = new Hono(); registerBackendRoutes(app); app.use("/*", serveStatic({ root: staticRoot, precompressed: true })); app.get("/*", async (c) => { const pathname = new URL(c.req.url).pathname; if (pathname.split("/").pop()?.includes(".")) return c.text("Not Found", 404); const html = await fs.readFile(indexHtmlPath, "utf-8"); return c.html(html); }); app.on(["HEAD"], "/*", async (c) => { const pathname = new URL(c.req.url).pathname; if (pathname.split("/").pop()?.includes(".")) return c.body(null, 404); return c.body(null, 200, { "Content-Type": "text/html; charset=UTF-8" }); }); return app; } ``` - [ ] **Step 4: Make index choose mode** In `apps/server/src/index.ts`, replace inline app creation with: ```ts import { pathToFileURL } from "node:url"; import { serve } from "@hono/node-server"; import { env } from "@reactive-resume/env/server"; import { createBackendApp, createMonolithApp } from "./app"; import { runStartupChecks } from "./lib/startup"; export function createApp() { return process.env.SERVER_MODE === "backend-only" ? createBackendApp() : createMonolithApp(); } async function main() { await runStartupChecks(); const port = process.env.NODE_ENV === "production" ? Number.parseInt(process.env.PORT ?? "3000", 10) : env.SERVER_PORT; const app = createApp(); serve( { fetch: app.fetch, port, }, (info) => { console.info(`🚀 Up and running on http://localhost:${info.port}`); }, ); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { await main(); } ``` - [ ] **Step 5: Verify** ```bash pnpm --filter server test -- src/app.test.ts src/index.test.ts pnpm --filter server typecheck ``` Expected: pass. - [ ] **Step 6: Commit** ```bash git add apps/server/src/app.ts apps/server/src/app.test.ts apps/server/src/index.ts apps/server/package.json git commit -m "feat(server): split backend and monolith app modes" ``` --- ## Task 6: Preserve Docker Monolith Contract **Files:** - Modify: `Dockerfile` - Modify: `compose.yml` - Modify: `compose.dev.yml` only if needed - Modify: `docs/deployment/architecture.mdx` - [ ] **Step 1: Make Docker default explicit** In `Dockerfile`, add: ```dockerfile ENV NODE_ENV="production" \ SERVER_MODE="monolith" \ PORT=3000 \ LOCAL_STORAGE_PATH=/app/data ``` - [ ] **Step 2: Document backend-only override** In `docs/deployment/architecture.mdx`, add: ```mdx ## Server Mode `SERVER_MODE=monolith` is the default Docker mode. `SERVER_MODE=backend-only` disables static serving and SPA fallback. Use this behind an external static host or edge frontend. ``` - [ ] **Step 3: Verify Docker build path at least compiles** Run: ```bash pnpm build ``` Expected: build passes. If full repo build is too noisy due unrelated dirty work, run: ```bash pnpm --filter web build pnpm --filter server build ``` Expected: both pass. - [ ] **Step 4: Commit** ```bash git add Dockerfile docs/deployment/architecture.mdx git commit -m "chore(docker): keep monolith server mode as default" ``` --- ## Task 7: Final Verification And Stale Reference Search **Files:** no new files. - [ ] **Step 1: Run focused tests** ```bash pnpm --filter @reactive-resume/server test pnpm --filter server test -- src/handlers/auth.test.ts src/index.test.ts src/app.test.ts pnpm --filter @reactive-resume/scripts test -- schema/generate.test.ts ``` Expected: all pass. - [ ] **Step 2: Run focused typechecks** ```bash pnpm --filter @reactive-resume/server typecheck pnpm --filter server typecheck pnpm --filter @reactive-resume/scripts typecheck pnpm --filter @reactive-resume/auth typecheck pnpm --filter web typecheck ``` Expected: all pass. - [ ] **Step 3: Verify static schema build** ```bash pnpm --filter @reactive-resume/scripts schema:generate pnpm --filter web build test -f apps/web/dist/schema.json cmp packages/schema/schema.json apps/web/public/schema.json ``` Expected: all pass. - [ ] **Step 4: Search stale route references** ```bash rg '"/auth/oauth"|/auth/oauth|handleSchemaJson|app\\.get\\("/schema\\.json"|SERVER_MODE|backend-only|monolith' apps packages docs Dockerfile AGENTS.md ``` Expected: - No runtime `/auth/oauth` references remain. - No `handleSchemaJson` references remain. - `SERVER_MODE`, `backend-only`, and `monolith` appear only in app composition, Docker, and docs. - [ ] **Step 5: Run non-mutating formatting/lint check on touched files** Use the repo's Biome config without writing: ```bash pnpm exec biome check apps/server/src apps/web/vite.config.ts packages/auth/src/config.ts packages/scripts packages/server docs/deployment AGENTS.md Dockerfile ``` Expected: no errors. If formatting errors appear, run Biome write only on touched source/doc files, then rerun this non-mutating check. --- ## Self-Review - Spec coverage: - Preserve single Docker image: Tasks 1 and 6. - Enable edge/S3/static frontend plus Node backend: Tasks 1, 2, 5, and 6. - Keep backend Node-first: Tasks 5 and 6. - Return 404 from backend for unknown split-mode routes: Task 5. - Move `/auth/oauth` to `/api/auth/oauth`: Task 3. - Make `/schema.json` static: Task 4. - Keep `/.well-known/*` backend-owned: Tasks 1 and 5. - Placeholder scan: - No TBD/TODO/fill-in steps remain. - Type consistency: - Route ownership terms are consistently `backend`, `static`, and `spa`. - Server modes are consistently `monolith` and `backend-only`. - The OAuth bridge route is consistently `/api/auth/oauth`. - Static schema targets are consistently `packages/schema/schema.json` and `apps/web/public/schema.json`.