docs/superpowers/plans/2026-05-14-monolith-and-split-deployment-architecture.md
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 currentapps/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.
/api/auth/oauth./.well-known/* backend-owned because OAuth/MCP metadata is runtime-owned./schema.json frontend/static-owned by generating it during build/source preparation.404.Audience: OSS users and current Docker image consumers.
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
Audience: Advanced self-hosters or the project owner's VPS migration path.
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
Audience: Cloud hosted Reactive Resume.
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
/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.
/schema.json
/assets/*
/templates/*
/favicon.ico
/favicon.svg
/manifest.webmanifest
/robots.txt
/sitemap.xml
/fonts/*
/icon/*
/logo/*
/opengraph/*
/photos/*
/pwa-*.png
/sounds/*
/videos/*
/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.
packages/server
createBackendApp() and createMonolithApp().apps/server/src/index.ts
@reactive-resume/server/node.apps/server/src/handlers/*
packages/server only after package boundaries are clear.packages/auth/src/config.ts
loginPage and consentPage to /api/auth/oauth.apps/server/src/handlers/auth.ts
/api/auth/oauth.packages/scripts/schema/generate.ts
apps/web/public/schema.json
apps/web/package.json
apps/web/vite.config.ts
Dockerfile
docs/deployment/architecture.mdx
docs/deployment/split-vps.mdx
docs/deployment/edge-frontend-node-backend.mdx
AGENTS.md
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:
# 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`.
docs/deployment/split-vps.mdxInclude this minimal NGINX shape:
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.
docs/deployment/edge-frontend-node-backend.mdxInclude:
# 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`.
AGENTS.mdAdd a short normative section:
### 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.
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"
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:
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");
});
});
Create packages/server/package.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:
{
"extends": "@reactive-resume/config/tsconfig.base.json"
}
Create packages/server/vitest.config.ts:
import { createVitestProjectConfig } from "@reactive-resume/config/vitest";
export default createVitestProjectConfig({
name: "@reactive-resume/server",
});
pnpm --filter @reactive-resume/server test -- src/routes/ownership.test.ts
Expected: fails because ownership.ts does not exist.
Create packages/server/src/routes/ownership.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";
}
pnpm --filter @reactive-resume/server test -- src/routes/ownership.test.ts
pnpm --filter @reactive-resume/server typecheck
Expected: both pass.
git add packages/server
git commit -m "feat(server): add route ownership contract"
/api/auth/oauthFiles:
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:
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");
Create apps/server/src/index.test.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();
});
});
pnpm --filter server test -- src/handlers/auth.test.ts src/index.test.ts
Expected: tests fail because the implementation still uses /auth/oauth.
In apps/server/src/index.ts, register the custom route before Better Auth catch-all:
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
app.on(["GET", "POST"], "/api/auth/*", (c) => handleAuth(c.req.raw));
Remove:
app.get("/auth/oauth", (c) => handleOAuth(c.req.raw));
In apps/server/src/handlers/auth.ts, change:
loginUrl.searchParams.set("callbackURL", `/auth/oauth?${oauthParams.toString()}`);
to:
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth?${oauthParams.toString()}`);
In packages/auth/src/config.ts, change:
oauthProvider({
loginPage: "/api/auth/oauth",
consentPage: "/api/auth/oauth",
In apps/web/vite.config.ts, remove the "/auth/oauth" proxy entry. /api/auth/oauth is covered by the existing "/api" proxy.
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.
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"
/schema.json StaticFiles:
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:
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);
});
});
In packages/scripts/package.json, add:
"schema:generate": "tsx schema/generate.ts",
"test": "vitest run --passWithNoTests"
Add missing dev dependencies:
"@reactive-resume/schema": "workspace:*",
"vitest": "^4.1.6",
"zod": "^4.4.3"
pnpm --filter @reactive-resume/scripts test -- schema/generate.test.ts
Expected: fails because generate.ts does not exist.
Create packages/scripts/schema/generate.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<string | URL> = 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();
}
In apps/web/package.json, add:
"prebuild": "pnpm --filter @reactive-resume/scripts schema:generate"
Keep:
"build": "rm -rf dist && vite build"
In apps/server/src/index.ts, remove the handleSchemaJson import and:
app.get("/schema.json", () => handleSchemaJson());
In apps/server/src/handlers/metadata.ts, remove:
import z from "zod";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
and delete handleSchemaJson().
In apps/web/vite.config.ts, remove the "/schema.json" proxy entry.
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.
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"
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:
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" });
});
});
pnpm --filter server test -- src/app.test.ts
Expected: fails because app.ts does not exist.
Create apps/server/src/app.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;
}
In apps/server/src/index.ts, replace inline app creation with:
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();
}
pnpm --filter server test -- src/app.test.ts src/index.test.ts
pnpm --filter server typecheck
Expected: pass.
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"
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:
ENV NODE_ENV="production" \
SERVER_MODE="monolith" \
PORT=3000 \
LOCAL_STORAGE_PATH=/app/data
In docs/deployment/architecture.mdx, add:
## 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.
Run:
pnpm build
Expected: build passes. If full repo build is too noisy due unrelated dirty work, run:
pnpm --filter web build
pnpm --filter server build
Expected: both pass.
git add Dockerfile docs/deployment/architecture.mdx
git commit -m "chore(docker): keep monolith server mode as default"
Files: no new files.
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.
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.
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.
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:
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.
/auth/oauth to /api/auth/oauth: Task 3./schema.json static: Task 4./.well-known/* backend-owned: Tasks 1 and 5.backend, static, and spa.monolith and backend-only./api/auth/oauth.packages/schema/schema.json and apps/web/public/schema.json.