commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:13:38 2026 +0200
chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies
commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:08:04 2026 +0200
fix: remove timestamp conflict guard
commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 12:11:51 2026 +0200
chore(release): v5.1.5
commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:35 2026 +0200
revert: compose.yml
commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:08 2026 +0200
refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086
commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:10:41 2026 +0200
refactor(pdf): simplify sidebar section filtering and update summary feature logic
commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:53:37 2026 +0200
chore: update pnpm-lock.yaml and turbo.json
commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:38:30 2026 +0200
fix(polyfill): add tested polyfill for Map Upsert methods
commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:23:29 2026 +0200
Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration
# Conflicts:
# packages/api/src/services/agent-url.ts
# packages/runtime-externals/package.json
commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:22:12 2026 +0200
chore: preserve branch changes before main sync
commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Mon May 18 07:50:28 2026 +0200
chore: lot of fixes for monorepo migration
commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 13:57:17 2026 +0200
chore: update knip version and refine web app routing with new SEO endpoints
commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 12:10:06 2026 +0200
refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint
commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:54:29 2026 +0200
chore: update dependencies and enhance PWA metadata in web app
commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:12:35 2026 +0200
docs: revise manifest-only pwa testing scope
commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:11:33 2026 +0200
docs: add manifest-only pwa design
commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:05:04 2026 +0200
chore(dev): simplify server proxy config
commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:50 2026 +0200
docs: add unsafe oauth redirect plan
commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:34 2026 +0200
feat(auth): add unsafe oauth redirect flag
commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:55:02 2026 +0200
docs: design unsafe oauth redirect flag
commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:22:04 2026 +0200
chore: update translation source paths
commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:09:25 2026 +0200
refactor(arch): react spa + hono migration
commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 01:10:47 2026 +0200
docs: add docker nightly tagging design
commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Thu May 14 20:05:44 2026 +0200
feat: migrate to hono spa server
33 KiB
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 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.
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.jsonfrontend/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.
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.
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.
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
/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
/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
/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()andcreateMonolithApp().
- 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.
- Becomes a thin Node entrypoint around
- Modify
apps/server/src/handlers/*- Move or re-export handlers into
packages/serveronly after package boundaries are clear. - Keep this as a later task if direct movement is too large.
- Move or re-export handlers into
- Modify
packages/auth/src/config.ts- Change OAuth provider
loginPageandconsentPageto/api/auth/oauth.
- Change OAuth provider
- Modify
apps/server/src/handlers/auth.ts- Change callback preservation to
/api/auth/oauth.
- Change callback preservation to
- 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:
# 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:
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:
# 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:
### 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
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:
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:
{
"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",
});
- Step 3: Run the failing tests
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:
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
pnpm --filter @reactive-resume/server test -- src/routes/ownership.test.ts
pnpm --filter @reactive-resume/server typecheck
Expected: both pass.
- Step 6: Commit
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:
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:
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
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:
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));
- Step 5: Update callback preservation
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()}`);
- Step 6: Update Better Auth OAuth provider pages
In packages/auth/src/config.ts, change:
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
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"
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:
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:
"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"
- Step 3: Run failing generator test
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:
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();
}
- Step 5: Wire generation into web build
In apps/web/package.json, add:
"prebuild": "pnpm --filter @reactive-resume/scripts schema:generate"
Keep:
"build": "rm -rf dist && vite build"
- Step 6: Remove backend runtime schema route
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().
- 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
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
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:
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
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:
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:
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
pnpm --filter server test -- src/app.test.ts src/index.test.ts
pnpm --filter server typecheck
Expected: pass.
- Step 6: Commit
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.ymlonly 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
- Step 2: Document backend-only override
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.
- Step 3: Verify Docker build path at least compiles
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.
- Step 4: Commit
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
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
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
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
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/oauthreferences remain. -
No
handleSchemaJsonreferences remain. -
SERVER_MODE,backend-only, andmonolithappear 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.
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/oauthto/api/auth/oauth: Task 3. - Make
/schema.jsonstatic: 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, andspa. - Server modes are consistently
monolithandbackend-only. - The OAuth bridge route is consistently
/api/auth/oauth. - Static schema targets are consistently
packages/schema/schema.jsonandapps/web/public/schema.json.
- Route ownership terms are consistently