cm_bot_v2/docs/superpowers/specs/2026-05-02-b1-nextjs-scaffold-design.md

19 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-nextcm-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:

  1. Renders a single placeholder page so the operator can confirm the service is up.
  2. Provides four Route Handler endpoints that proxy to api-server:3000 with byte-equivalent behavior to the existing Flask cm_web_view.py proxies — 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.)
  3. Builds reproducibly through scripts/publish.sh like the four existing services.
  4. 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.py or removing the existing cm-web Flask service. B4's job after the UI is fully ported.
  • Renaming the cm-web-next service to cm-web during 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

Route Handler proxy

A single catch-all Route Handler at web/app/api/[...path]/route.ts forwards all /api/* requests to api-server:3000 (the upstream service name on the compose network). Implementation sketch:

import { NextRequest, NextResponse } from "next/server";

const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";

async function forward(request: NextRequest, path: string[]) {
  const targetPath = "/" + path.join("/");
  const trailingSlash = request.nextUrl.pathname.endsWith("/") ? "/" : "";
  const target = `${API_BASE_URL}${targetPath}${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 today are mechanically identical proxies; 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 500) matches the Flask version (return jsonify({"error": str(e)}), 500).

The trailing-slash preservation means GET /api/acc/ proxies to api-server:3000/acc/ (Flask's exact pattern); POST /api/update-acc-data proxies to api-server:3000/update-acc-data. Same shape as cm_web_view.py:proxy_acc(), proxy_update_acc(), etc.

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 text cm-web-next scaffold so 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-side cm-web-next service.
  • Add a one-line note under "Dev Tier" pointing to http://localhost:8010/ for the new UI alongside http://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

  1. web/ builds locally without docker. cd web && npm install && npm run build succeeds (smoke for someone editing TS without rebuilding the container each time).
  2. bash scripts/dev.sh up brings up five services. mysql, api-server, web-view (Flask, port 8000), web-next (Next.js, port 8010). docker compose ps shows all five running.
  3. 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.
  4. Proxy parity. curl -s http://localhost:8010/api/acc/ | head -c 200 returns the same JSON shape as curl -s http://localhost:8000/api/acc/ | head -c 200 (both proxy to api-server:3000/acc/).
  5. POST proxy. curl -i -X POST -H 'Content-Type: application/json' -d '{"username":"13c1000","password":"x","status":"","link":""}' http://localhost:8010/api/update-acc-data returns the same response (and same exit status) as the same POST against the Flask web-view on port 8000.
  6. Image publishes. bash scripts/publish.sh dev publishes cm-web-next:dev alongside the other four images. (Skip in CI; smoke check on the operator's machine when ready.)
  7. Old cm-web still works. curl -s http://localhost:8000/ still returns the Flask HTML page. Side-by-side preserved.
  8. Prod parity check. docker compose -f docker-compose.yml config | grep -E "web-next" | head shows the new service. docker compose -f docker-compose.yml config | grep "ports:" -A 1 confirms web-next is on ${CM_WEB_NEXT_HOST_PORT:-8010}:3000 and web-view is 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-web retires 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: true in Next.js means /api/acc redirects 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 .dockerignore excludes __pycache__, *.py[cod], *.log, .git, logs/, node_modules/. The node_modules/ entry already prevents copying the host's web/node_modules/ if the operator ran npm install on the host — good. We don't need a new repo-root .dockerignore change.

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, retire cm-web service, rename cm-web-nextcm-web (and same for the image), update aaPanel-hardening guide and dev.sh accordingly.
  • Hot-reload in devcommand: ["npm", "run", "dev"] in the override + bind-mount web/ 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.