348 lines
19 KiB
Markdown
348 lines
19 KiB
Markdown
# 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 `<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:
|
|
|
|
```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 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).
|
|
|
|
```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/<name>/.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:<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-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.
|