From 572b200603388230bf119cdbe7b4318808f5a494 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 2 May 2026 18:07:47 +0800 Subject: [PATCH] Add design spec for B1 (Next.js scaffold + side-by-side deploy) --- .../2026-05-02-b1-nextjs-scaffold-design.md | 359 ++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-02-b1-nextjs-scaffold-design.md diff --git a/docs/superpowers/specs/2026-05-02-b1-nextjs-scaffold-design.md b/docs/superpowers/specs/2026-05-02-b1-nextjs-scaffold-design.md new file mode 100644 index 0000000..512e7ee --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-b1-nextjs-scaffold-design.md @@ -0,0 +1,359 @@ +# 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](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: + +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 `

cm-web-next scaffold

` 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//.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: + +```typescript +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 centered card confirming the service is up: + +```tsx +export default function Home() { + return ( +
+
+

cm-web-next scaffold

+

+ B1 scaffold. UI lands in B2. +

+

+ Try{" "} + + /api/acc/ + {" "} + to verify the proxy reaches{" "} + api-server:3000. +

+
+
+ ); +} +``` + +`web/app/layout.tsx` is the standard `create-next-app` boilerplate with ``, body class for Tailwind defaults, and a tab title `CM Bot V2`. + +`web/app/globals.css` is the Tailwind v4 import (`@import "tailwindcss"`). + +### 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). + +```dockerfile +# 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` + +```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`: + +```yaml + # 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): + +```yaml + 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//.env.example .env` for fresh clones). + +### `scripts/dev.sh` + +Modify the `up`, `logs`, and `up` invocation in `reset-db` to include `web-next`: + +```bash + 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: + +```bash +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:`, 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. + +## Out-of-Scope Follow-Ups + +- **B2** — port the UI: tabs, two tables, sortable columns, inline cell editing, refresh, stats cards, error states. +- **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-next` → `cm-web` (and same for the image), update aaPanel-hardening guide and `dev.sh` accordingly. +- **Hot-reload in dev** — `command: ["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.