22 KiB
B1: Next.js Scaffold + Side-by-Side Deploy + Proxy Parity Design
Date: 2026-05-02
Status: Approved (design)
Sequel to: 2026-05-02-prod-hardening-c1-c5-c6-design.md
Followed by: B2 (UI port), B3 (PWA), B4 (cutover — delete app/cm_web_view.py, rename cm-web-next → cm-web).
Problem
app/cm_web_view.py is a 758-line Flask app with inline HTML/CSS/JS. The user wants to migrate the UI to Next.js for a modern stack (App Router, React, Tailwind, PWA-ready). The full migration is multi-cycle; this cycle (B1) only ships the scaffold and deploy story — empty UI, parity-only proxies — so we can verify the build/deploy pipeline before pouring effort into the UI port (B2). The existing Flask UI keeps working in parallel during B2/B3.
Goal
Stand up a new cm-web-next Docker service running a Next.js 15 app at web/ in this repo, exposed on host port ${CM_WEB_NEXT_HOST_PORT:-8010} (default 8010 — non-conflicting with 8000/dev, 8001/rex, 8005/siong). The app:
- Renders a single placeholder page so the operator can confirm the service is up.
- Provides four Route Handler endpoints that proxy to
api-server:3000with byte-equivalent behavior to the existing Flaskcm_web_view.pyproxies —GET /api/acc/,GET /api/user/,POST /api/update-acc-data,POST /api/update-user-data. (B2 will start using these from React; B4 will retire the Flask versions.) - Builds reproducibly through
scripts/publish.shlike the four existing services. - Comes up automatically with
bash scripts/dev.sh up.
The existing cm-web (Flask) service is untouched. Both services run side-by-side in dev and prod until B4 cuts over.
Non-Goals
- Any actual UI work. The page reads
<h1>cm-web-next scaffold</h1>plus a one-line confirmation that the API proxies are reachable. Tabs, tables, inline editing, sort, refresh — all in B2. - PWA /
manifest.webmanifest/ service worker / icons — all in B3. - Auth — handled by aaPanel (C3 basic auth covers the new vhost too).
- Tests in B1. The scaffold is mostly config and glue; integration smoke is sufficient. B2 will introduce vitest when there's actual UI logic to test.
- Migrating
app/cm_web_view.pyor removing the existingcm-webFlask service. B4's job after the UI is fully ported. - Renaming the
cm-web-nextservice tocm-webduring this cycle. The rename is part of B4 along with the Flask retirement.
Architecture
Repo layout
cm_bot_v2/
├── app/ ← Python services (unchanged)
├── docker/
│ ├── api/, web/, telegram/, transfer/ ← Python service Dockerfiles
│ └── web-next/Dockerfile ← NEW
├── web/ ← NEW: full Next.js 15 project root
│ ├── package.json
│ ├── package-lock.json
│ ├── next.config.ts
│ ├── tsconfig.json
│ ├── tailwind.config.ts
│ ├── postcss.config.mjs
│ ├── .gitignore
│ ├── .dockerignore
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── globals.css
│ │ └── api/
│ │ └── [...path]/
│ │ └── route.ts ← Catch-all proxy to api-server
│ └── public/ ← (empty for B1; B3 adds icons)
├── docker-compose.yml ← + cm-web-next service
├── docker-compose.override.yml ← + cm-web-next build directive
├── envs/<name>/.env.example ← + CM_WEB_NEXT_HOST_PORT
└── scripts/
├── dev.sh ← MODIFIED: include cm-web-next in up/logs
└── publish.sh ← MODIFIED: + cm-web-next image
web/ is at the repo root (per L3 location decision), parallel to app/. It is its own JavaScript/TypeScript project with its own package.json, completely independent of the Python tree.
Tech stack
| Choice | Value | Rationale |
|---|---|---|
| Next.js | 15.x stable | Modern, App Router, Route Handlers |
| Language | TypeScript | Next.js default; tooling assumes it |
| Styling | Tailwind CSS v4 | What create-next-app ships in 2026 |
| Routing | App Router (app/ dir) |
Modern; B2/B3 conventions cleaner |
| Build output | output: 'standalone' in next.config.ts |
Slim runtime image (~150MB vs 1GB+) |
| Package manager | npm | Zero ceremony, default tooling |
| Node | 22 LTS (in container) | Current LTS line |
| Trailing slashes | trailingSlash: true |
Parity with Flask @app.route('/api/acc/') paths |
API path hashing — opaque public URLs
The four endpoints exposed by the Flask cm_web_view.py (/api/acc/, /api/user/, /api/update-acc-data, /api/update-user-data) are renamed at the public boundary using SHA-256 (truncated to 16 hex chars) of the endpoint name. The web is not designed for SEO and scanners shouldn't be tipped off to "an endpoint named acc exists":
| Logical name | Public path (web-next) | api-server upstream (unchanged) |
|---|---|---|
acc |
/api/414322309db5c06d/ |
/acc/ |
user |
/api/04f8996da763b7a9/ |
/user/ |
update-acc-data |
/api/982830e2982d95de/ |
/update-acc-data |
update-user-data |
/api/f1a25b37d8db494c |
/update-user-data |
Properties:
- Deterministic. No salt — same hash on rex, siong, dev. Operators reading code see a hash; running
python3 -c "import hashlib; print(hashlib.sha256(b'acc').hexdigest()[:16])"confirms the mapping. - Boundary only. Only
cm-web-nexttranslates hashes.api-server's Flask routes are untouched (/acc/,/user/, etc.). Sinceapi-serverhas no host port (per C5), scanners can't reach those names directly. - Trailing-slash preserved. GET endpoints keep the trailing slash (matching Flask), POST endpoints don't (matching Flask).
- Mapping lives in
web/lib/api-paths.ts. Both the Route Handler (server) and any future client React code (B2) read from the same exported constants. Hardcoded values — no runtime hashing on the request path.
web/lib/api-paths.ts:
// SHA-256(endpoint).hex[:16]. Deterministic; no salt. Public-boundary only:
// the upstream api-server still uses the readable names. See B1 spec.
export const API_PATHS = {
acc: "414322309db5c06d", // upstream: /acc/
user: "04f8996da763b7a9", // upstream: /user/
updateAcc: "982830e2982d95de", // upstream: /update-acc-data
updateUser: "f1a25b37d8db494c", // upstream: /update-user-data
} as const;
// Reverse map for the Route Handler. Keys are the public path segment,
// values are { upstream: string; trailingSlash: boolean }.
export const PUBLIC_TO_UPSTREAM: Record<string, { upstream: string; trailingSlash: boolean }> = {
[API_PATHS.acc]: { upstream: "acc", trailingSlash: true },
[API_PATHS.user]: { upstream: "user", trailingSlash: true },
[API_PATHS.updateAcc]: { upstream: "update-acc-data", trailingSlash: false },
[API_PATHS.updateUser]: { upstream: "update-user-data", trailingSlash: false },
};
If a request comes in for a hash that isn't in the mapping, the Route Handler returns 404 (not a 500 — it's literally not a route we expose).
Route Handler proxy
A single catch-all Route Handler at web/app/api/[...path]/route.ts forwards mapped /api/<hash>/ requests to api-server:3000 (the upstream service name on the compose network). The handler:
- Reads the first segment of
pathas the hash. - Looks it up in
PUBLIC_TO_UPSTREAMfromweb/lib/api-paths.ts. - If found, builds the upstream URL with the original
trailingSlashflag and forwards. - If not found, returns 404.
Implementation sketch:
import { NextRequest, NextResponse } from "next/server";
import { PUBLIC_TO_UPSTREAM } from "@/lib/api-paths";
const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";
async function forward(request: NextRequest, path: string[]): Promise<NextResponse> {
const hash = path[0];
const route = PUBLIC_TO_UPSTREAM[hash];
if (!route) {
return NextResponse.json({ error: "Not Found" }, { status: 404 });
}
const target = `${API_BASE_URL}/${route.upstream}${route.trailingSlash ? "/" : ""}`;
const init: RequestInit = {
method: request.method,
headers: { "content-type": request.headers.get("content-type") ?? "application/json" },
};
if (request.method !== "GET" && request.method !== "HEAD") {
init.body = await request.text();
}
try {
const upstream = await fetch(target, init);
const body = await upstream.text();
return new NextResponse(body, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return forward(req, (await ctx.params).path);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return forward(req, (await ctx.params).path);
}
Why catch-all instead of one file per endpoint:
- The four endpoints are mechanically identical proxies through a hash-table lookup; writing four near-duplicate files is a YAGNI violation.
- B2 may need additional methods or per-route logic; that's the time to split (and split only the routes that need it).
- The error shape (
{"error": str}with HTTP 5xx,{"error": "Not Found"}with 404) matches the Flask version (return jsonify({"error": str(e)}), 500) and adds 404 for unmapped paths.
The trailing-slash preservation comes from the per-route trailingSlash flag in the mapping, not from the request URL — keeps client and server in lockstep regardless of how the URL was typed.
Empty UI page
web/app/page.tsx renders a placeholder confirming the service is up. The implementation plan invokes the frontend-design skill to generate page.tsx and layout.tsx (per user direction — frontend-design owns all web design code in this codebase). The brief handed to the skill:
- Page purpose: scaffold-confirmation page for
cm-web-next. Shipped only in B1; replaced by the real dashboard in B2. - Required content: product name (
CM Bot V2), the literal textcm-web-next scaffoldso an operator can grep the page for it, a one-line "B2 lands the real UI" note, and an obvious link to/api/acc/so a smoke test of the proxy is one click away. - Design constraints: uses Tailwind v4 (already configured); no external font/image deps; no JS interactivity (Server Component is fine); single page, no nav. Should clearly read as a temporary scaffold (not a real dashboard) so nobody mistakes it for production UI.
- Out of scope: dark mode, responsive breakpoints beyond mobile-first defaults, animations.
web/app/layout.tsx is also generated by frontend-design — minimal <html lang="en"> shell with <body> Tailwind defaults and tab title CM Bot V2.
web/app/globals.css is the Tailwind v4 import (@import "tailwindcss";) — written by hand, no design surface.
Dockerfile
Multi-stage build at docker/web-next/Dockerfile, modeled after Next.js's official Dockerfile example. Uses output: 'standalone' so the runtime image only carries the standalone server bundle (~150MB total).
# syntax=docker/dockerfile:1.7
# --- deps ---
FROM node:22-alpine AS deps
WORKDIR /app
COPY web/package.json web/package-lock.json* ./
RUN npm ci
# --- build ---
FROM node:22-alpine AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY web/ ./
RUN npm run build
# --- runtime ---
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
The standalone server listens on $PORT. We expose 3000 inside the container and bind to a host port via compose (default 8010 → container 3000). Naming note: container port 3000 is internal; nothing else at this scale uses 3000 because api-server's host port was dropped in C5.
next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
output: "standalone",
trailingSlash: true,
};
export default config;
Compose changes
docker-compose.yml — add cm-web-next service near the existing web-view:
# Next.js Web View (side-by-side with web-view during B-cycle migration).
web-next:
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-web-next:${DOCKER_IMAGE_TAG:-latest}"
container_name: ${CM_DEPLOY_NAME:-cm}-web-next
restart: unless-stopped
ports:
- "${CM_WEB_NEXT_HOST_PORT:-8010}:3000"
environment:
NODE_ENV: production
NEXT_TELEMETRY_DISABLED: "1"
API_BASE_URL: http://api-server:3000
networks:
- bot-network
depends_on:
- api-server
docker-compose.override.yml — add a web-next block with build directive (parallel to existing services):
web-next:
build:
context: .
dockerfile: docker/web-next/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-web-next:${DOCKER_IMAGE_TAG:-dev}"
# No `command:` override — Next.js standalone server is the dev runtime
# for B1. (B2 may add a `command: ["npm", "run", "dev"]` to enable
# hot-reload; not in this cycle.)
The compose service name is web-next (not cm-web-next) for parity with the existing pattern (web-view, api-server, telegram-bot, transfer-bot). Container name resolves to ${CM_DEPLOY_NAME}-web-next via the existing pattern.
Env file changes
Add CM_WEB_NEXT_HOST_PORT to all three .env.example templates, with deployment-appropriate defaults:
| File | New line |
|---|---|
envs/dev/.env.example |
CM_WEB_NEXT_HOST_PORT=8010 |
envs/rex/.env.example |
CM_WEB_NEXT_HOST_PORT=8011 |
envs/siong/.env.example |
CM_WEB_NEXT_HOST_PORT=8012 |
The committed templates document the convention; each operator's gitignored .env is updated by hand (or by cp envs/<name>/.env.example .env for fresh clones).
scripts/dev.sh
Modify the up, logs, and up invocation in reset-db to include web-next:
up)
"${COMPOSE[@]}" up -d --build mysql api-server web-view web-next
"${COMPOSE[@]}" ps
;;
reset-db)
"${COMPOSE[@]}" down --volumes --remove-orphans
"${COMPOSE[@]}" up -d --build mysql api-server web-view web-next
;;
logs)
"${COMPOSE[@]}" logs -f mysql api-server web-view web-next
;;
scripts/publish.sh
Append web-next to the SERVICES array:
SERVICES=(
"api docker/api/Dockerfile"
"telegram docker/telegram/Dockerfile"
"web docker/web/Dockerfile"
"transfer docker/transfer/Dockerfile"
"web-next docker/web-next/Dockerfile"
)
The image name template ${REGISTRY_PREFIX}/cm-${SERVICE}:${IMAGE_TAG} produces gitea.04080616.xyz/yiekheng/cm-web-next:<tag>, matching the compose image: reference.
AGENTS.md updates
- Add a bullet under "Project Structure & Module Organization" describing
web/and the side-by-sidecm-web-nextservice. - Add a one-line note under "Dev Tier" pointing to
http://localhost:8010/for the new UI alongsidehttp://localhost:8000/for the legacy Flask UI.
web/.gitignore and web/.dockerignore
Standard Next.js ignores. web/.gitignore includes .next/, node_modules/, build/test outputs. web/.dockerignore excludes node_modules/, .next/, .git/, and the rest of the repo (we only need web/ in the build context, but Docker copies what we ask for via the explicit COPY web/... line).
Files Created / Modified
| File | Operation |
|---|---|
web/package.json |
Create — Next.js 15 deps, scripts |
web/package-lock.json |
Create — generated by npm install |
web/next.config.ts |
Create — standalone + trailingSlash |
web/tsconfig.json |
Create — create-next-app defaults |
web/tailwind.config.ts |
Create — Tailwind v4 default |
web/postcss.config.mjs |
Create — Tailwind v4 PostCSS plugin |
web/app/layout.tsx |
Create — title + Tailwind globals |
web/app/page.tsx |
Create — placeholder card |
web/app/globals.css |
Create — @import "tailwindcss"; |
web/app/api/[...path]/route.ts |
Create — catch-all proxy |
web/.gitignore |
Create — Next.js standard |
web/.dockerignore |
Create — exclude node_modules / .next |
docker/web-next/Dockerfile |
Create — multi-stage Node 22 alpine |
docker-compose.yml |
Modify — add web-next service |
docker-compose.override.yml |
Modify — add web-next build directive |
envs/dev/.env.example |
Modify — CM_WEB_NEXT_HOST_PORT=8010 |
envs/rex/.env.example |
Modify — CM_WEB_NEXT_HOST_PORT=8011 |
envs/siong/.env.example |
Modify — CM_WEB_NEXT_HOST_PORT=8012 |
scripts/dev.sh |
Modify — include web-next in up/logs/reset-db |
scripts/publish.sh |
Modify — append web-next to SERVICES |
AGENTS.md |
Modify — note new service + UI URL |
No file removals. Nothing in app/ is touched.
Verification
web/builds locally without docker.cd web && npm install && npm run buildsucceeds (smoke for someone editing TS without rebuilding the container each time).bash scripts/dev.sh upbrings up five services.mysql,api-server,web-view(Flask, port 8000),web-next(Next.js, port 8010).docker compose psshows all fiverunning.- Empty page renders.
curl -s http://localhost:8010/ | grep -E "cm-web-next scaffold|B1 scaffold"returns hits. Open in a browser → centered card visible with the link. - Proxy parity.
curl -s http://localhost:8010/api/acc/ | head -c 200returns the same JSON shape ascurl -s http://localhost:8000/api/acc/ | head -c 200(both proxy toapi-server:3000/acc/). - POST proxy.
curl -i -X POST -H 'Content-Type: application/json' -d '{"username":"13c1000","password":"x","status":"","link":""}' http://localhost:8010/api/update-acc-datareturns the same response (and same exit status) as the same POST against the Flaskweb-viewon port 8000. - Image publishes.
bash scripts/publish.sh devpublishescm-web-next:devalongside the other four images. (Skip in CI; smoke check on the operator's machine when ready.) - Old
cm-webstill works.curl -s http://localhost:8000/still returns the Flask HTML page. Side-by-side preserved. - Prod parity check.
docker compose -f docker-compose.yml config | grep -E "web-next" | headshows the new service.docker compose -f docker-compose.yml config | grep "ports:" -A 1confirmsweb-nextis on${CM_WEB_NEXT_HOST_PORT:-8010}:3000andweb-viewis unchanged on${CM_WEB_HOST_PORT:-8001}:8000.
Risk
Low.
- Side-by-side means double the surface for now. Two web services running, two host ports, two images. The cost is real but bounded — once B2 is feature-complete and B4 cuts over, the old
cm-webretires and we're back to one. The alternative (in-place rewrite) was higher risk because broken B2 commits would break prod. - Catch-all Route Handler regex semantics. Next.js's
[...path]matches any depth; the explicit Flask routes are flat (e.g.,/api/acc/). If any future api-server endpoint accidentally collides with a Next.js convention path (/api/_next/...,/api/__nextjs_original-stack-frames), behavior could be surprising. None of these conflicts exist today (api-server's routes are/acc/,/user/,/update-acc-data,/update-user-data). - Trailing slashes.
trailingSlash: truein Next.js means/api/accredirects to/api/acc/with a 308. The Flask version always required the slash. Behavior is parity-equivalent for clients that include the slash; clients that don't get a redirect they didn't get before. The UI we control will always include it. - Build context size. Docker build context includes the whole repo unless we set up
.dockerignore. The repo's existing.dockerignoreexcludes__pycache__,*.py[cod],*.log,.git,logs/,node_modules/. Thenode_modules/entry already prevents copying the host'sweb/node_modules/if the operator rannpm installon the host — good. We don't need a new repo-root.dockerignorechange.
Frontend-design conventions
All web design code in this codebase (TSX components, page layouts, CSS-in-class Tailwind, etc.) goes through the frontend-design skill rather than being hand-written. This decision applies to B1's placeholder, B2's full UI port, and any subsequent UI work. Glue code (Route Handlers, next.config.ts, env wiring, the Dockerfile, package.json, etc.) is written directly.
Practical implication for the implementation plan: there is one task that explicitly invokes frontend-design with the brief described in the "Empty UI page" section above and writes the returned page.tsx and layout.tsx into web/app/. Subsequent edits to those files in B2 follow the same pattern.
Out-of-Scope Follow-Ups
- B2 — port the UI: tabs, two tables, sortable columns, inline cell editing, refresh, stats cards, error states. Implementation invokes
frontend-design. - B3 — PWA:
manifest.webmanifest,next-pwa(or hand-rolled service worker), 192/512 icons, install prompt. - B4 — cutover: delete
app/cm_web_view.py, retirecm-webservice, renamecm-web-next→cm-web(and same for the image), update aaPanel-hardening guide anddev.shaccordingly. - Hot-reload in dev —
command: ["npm", "run", "dev"]in the override + bind-mountweb/into the container. Useful but not necessary for B1; revisit when B2 starts and iteration speed matters. - Vitest — unit tests for components/route handlers. B2 lays the foundation when there's actual logic.