chart/docs/vite_build.md

356 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Vite build — from CDN script tags to a real frontend
**Status: tracked, not started.** A direction to refactor toward, not a project
with a date. Each phase below is worth shipping on its own; none of it is
speculative scaffolding for a component rewrite.
Today `static/index.html` loads Vue 3, Lightweight Charts 5.2.0 and Font Awesome
7.3.1 from unpkg, then two plain scripts. FastAPI serves those files and stamps
`?v=` onto every `/static/` URL. Production is Coolify + nixpacks + a Python
`Procfile`. That is the setup this document replaces.
## The goal is not "more Vue"
The target is **a pinned, hashed, minified, same-origin frontend** that we can
grow without unpkg and without a Python hasher. View-source today is the
entire app. After this, a casual reader should not get `app.js` back. It is
not a component split, not TypeScript, not a router, and not leaving Coolify.
- `chart.js` stays a plain class. Vue still must not wrap chart or series
objects in `ref()` / `reactive()`.
- `window.__chart` stays. E2E and diagnostic work depend on it.
- One `App.vue` holding today's template and `setup()`. Do not extract the
color picker or the tool panels in the same change.
- Stay on Coolify. The friction is nixpacks autodetection, not the platform.
## What is already right
- Vue 3 Composition API in `static/app.js` (`createApp`, `ref`, `computed`,
`watch`, `onMounted`). That maps 1:1 onto `vue` from npm.
- `ConfluenceChart` is already framework-free. It only needs `export` instead
of `window.ConfluenceChart`, and ESM named imports instead of the
`LightweightCharts` global.
- FastAPI already owns `/`, `/api`, `/ws`. The built SPA still comes from
that origin. Do not put a Vite server in production.
- Asset hashing exists because a tab left open kept running yesterday's JS
(`main.asset_version`, `tests/test_asset_versioning.py`). Vite's content
hashes replace that rewriter; the *reason* does not go away.
## Constraints this repo will punish you for forgetting
- **The agent is on a different machine from the user's browser.** Local Vite
on `localhost:5173` is invisible to them. Whatever serves the UI in dev must
still be reachable as `hera.local:8010` (or whatever host port compose
publishes). HMR has to work across that hop, or we do not use HMR.
- **`--reload` plus an 82-second seed.** Never put a scratch `.py` in the repo
root. Frontend files are safe; uvicorn watches Python. Do not "help" by
adding a Python build helper at the root.
- **Every push to `main` is a production deploy**, and a deploy restarts the
market stream. The Vite cutover is one of those deploys. Land the production
Dockerfile *before* a root `package.json` exists, or nixpacks may decide
this is a Node app and the site goes dark.
- **E2E hits `http://api:8000`**, waits on `window.__chart.bars`, and uses
`--lang=en-US`. None of that changes. A blank canvas after the move is
still the locale bug until proven otherwise.
- **Pin what unpkg currently pins.** Lightweight Charts **5.2.0** and Font
Awesome **7.3.1**. Vue's CDN tag is `vue@3` (floating). Pin a current Vue
3.x on the way in; do not upgrade LWC in this work. v5 series creation is
`chart.addSeries(CandlestickSeries, opts)` — the v4 helpers do not exist.
- **One uvicorn worker, forever**, until the streamer is a separate process.
The Dockerfile `CMD` is the `Procfile` line. Do not add `--workers`.
## Target layout
```
frontend/
package.json
package-lock.json committed
vite.config.js
index.html Vite entry; empty #app
src/
main.js createApp(App).mount('#app')
App.vue today's markup + today's setup()
chart.js export class ConfluenceChart
style.css moved from static/
dist/ gitignored; Vite outDir, served by FastAPI
Dockerfile production; Coolify prefers this over nixpacks
```
`static/` goes away when FastAPI is serving `dist/` and the e2e suite is green.
Do not keep both as a fallback — a missed build would silently serve the CDN
app.
Suggested `frontend/src/main.js`:
```js
import { createApp } from 'vue';
import '@fortawesome/fontawesome-free/css/all.min.css';
import './style.css';
import App from './App.vue';
createApp(App).mount('#app');
```
Suggested chart import (names used today):
```js
import {
createChart,
CandlestickSeries,
HistogramSeries,
LineSeries,
LineStyle,
LineType,
CrosshairMode,
TickMarkType,
} from 'lightweight-charts';
```
Keep `export default { setup() { ... return { ... }; } }` in `App.vue`.
`<script setup>` is a rewrite of the return bag for no gain.
## Dev: same origin, same port
A Vite dev server on 5173 is the usual tutorial and the wrong default here.
The user's browser already has one URL. Adding a second public port, plus an
HMR websocket that has to reach a remote host, is how this loses a day.
**Default:** a Node sidecar runs `vite build --watch` into `dist/`. The
existing `api` service serves that directory at `/` exactly as production
will. Compose still publishes one port. Edits to `.vue` / `.js` / `.css`
rebuild hashed assets; the next refresh picks them up. No HMR, no second
origin, no proxy for `/ws`.
```yaml
frontend:
image: node:22-alpine
working_dir: /app/frontend
volumes:
- .:/app
command: sh -c "npm ci && npm run build -- --watch"
```
`Dockerfile.dev` stays Python-only. Do not install Node in the API image.
Optional later, not part of the move: `vite` with `server.host: true` and
`server.hmr` pointed at the machine the *browser* can see. Only worth it if
the watch-and-refresh loop is actually painful.
`vite.config.js` needs little for the default path — `base: '/'`,
`build.outDir` set so FastAPI and the watcher agree (repo-root `dist/` or
`frontend/dist/`, pick one and use it everywhere). Production minify is a
requirement, not an option; see below. No `/api` proxy until someone runs
the Vite dev server.
## Production minify
Vite's production build already minifies JS and CSS with esbuild. Keep that
on. Do not set `build.minify: false` to "make debugging easier" — that is
what the source tree is for.
```js
build: {
minify: 'esbuild',
sourcemap: false,
cssMinify: true,
}
```
**No source maps in what FastAPI serves.** A `.map` file next to the bundle
is the original source with a different URL. `sourcemap: false` is the
default; do not turn it on in the config that Coolify builds. Local
debugging reads `frontend/src/`, not a map shipped to the browser.
**Do not put the source tree on the production image.** The multi-stage
`COPY . .` below would otherwise copy `frontend/src/` into the container.
Even unmounted, that is one Traefik mistake away from being public. The
final stage copies `dist/` only. `.dockerignore` must list `frontend/`.
`vite build --watch` in compose is a production build in a loop, so local
and prod stay equally minified. That is what we want. Slower than HMR;
acceptable.
This is a speed bump, not a lock. `window.__chart` remains a deliberate
debug handle and e2e depends on it — anyone who knows to open the console
still has the wrapper. Minify so View Source is not the codebase; do not
delete `__chart` to chase real secrecy.
## Production: Dockerfile, not nixpacks
Coolify builds a Dockerfile if one exists, and ignores the `Procfile`. Land
that switch as its own deploy, *reproducing today's image*, before the
frontend exists:
```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```
Then, when `frontend/` exists, make it multi-stage:
```dockerfile
FROM node:22-alpine AS frontend
WORKDIR /src
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py Procfile ./
COPY app ./app
COPY --from=frontend /src/dist /app/dist
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```
The final stage must not `COPY . .` once `frontend/` exists — that would
ship the unminified source next to the bundle. Copy the Python package and
`dist/` only.
Add a `.dockerignore` so `data/`, `.venv`, `node_modules`, `artifacts/`,
`.env` and `frontend/` never enter the *final* build context. A missing
ignore is how the Schwab token, the drawing store, or `App.vue` gets baked
into an image.
Unchanged, and not Coolify's problem:
- env vars (`CHART_PASSWORD`, `LIVE_SOURCE`, Schwab keys, ntfy)
- the persistent volume at `/app/data`
- `SOURCE_COMMIT` → `/api/version` → `bin/wait-deploy`
- the domain registered as `chart.amow.com:8000` (Traefik target port)
The first multi-stage deploy will be a **cold** build (Node layer is new).
Expect the ~90s end of the current range, plus `npm ci`. The old container
keeps serving until the swap; ignore the usual one-minute 502.
## FastAPI after the cutover
`GET /` reads `dist/index.html` and still sends `Cache-Control: no-store`.
The document must never be cached, or the hashed filenames inside it are the
stale thing instead — same reason as today.
Mount Vite's hashed directory, not a rewrite pass:
```python
app.mount("/assets", StaticFiles(directory=DIST_DIR / "assets"), name="assets")
```
Those files can be cached for a long time (`immutable`, or a one-year
`max-age`). Vite changes the filename when the content changes.
Delete `asset_version()` and the `ASSET_REF` rewrite. They hash `static/*`
and would either no-op or stamp `?v=` onto URLs Vite already uniquely named.
`tests/test_asset_versioning.py` keeps its purpose, changes its evidence:
- `/` is `no-store` and references `/assets/…` with a content hash
- hashed asset URLs do not need `?v=`
- a rebuild after editing a frontend source file changes the hash in the
HTML (this one needs the built `dist/` in the test fixture, or a tiny
committed stub `dist/` used only by that test — do not hit `npm` from
pytest)
## Phases
Each is independently deployable. Do not fold 2–4 into the Dockerfile PR.
### Phase 1 — Production Dockerfile, still CDN
Add `Dockerfile` + `.dockerignore`. Confirm `git push && bin/wait-deploy`
and `/api/version`. nixpacks is gone; the site is byte-identical.
This is the phase that makes a later `package.json` safe.
### Phase 2 — Scaffold `frontend/`, no cutover
`npm create vite@latest` (Vue, JS, no TS). Pin `vue`, `lightweight-charts@5.2.0`,
`@fortawesome/fontawesome-free@7.3.1`. Commit `package-lock.json`. Add
`node_modules/` and `dist/` to `.gitignore`.
Do not add `package.json` at the repo root. nixpacks is already gone after
phase 1; keep Node metadata under `frontend/` anyway so a future builder
cannot mis-detect the app.
### Phase 3 — Move the two files, same behaviour
- `static/chart.js` → `frontend/src/chart.js` with ESM imports and `export`.
Drop `window.ConfluenceChart`.
- `static/app.js` `setup()` + the `#app` inner HTML → `frontend/src/App.vue`.
`import { ConfluenceChart } from './chart.js'`. Keep assigning
`window.__chart = chartApi` in `onMounted`.
- `static/style.css` → `frontend/src/style.css`.
- Font Awesome via the npm CSS import, not the unpkg `<link>`.
The global Vue build includes the compiler. Vite's Vue plugin compiles SFCs
and ships the runtime-only build. That is why the markup has to live in
`App.vue` (or another compiled module), not as HTML children of `#app`.
`npm run build` locally. Open the `dist/` preview against a running API only
if you need a sanity check; the real proof is phase 4.
### Phase 4 — FastAPI serves `dist/`, delete `static/`
Point `index()` and the static mount at `dist/`. Add the compose `frontend`
watcher. Rewrite `test_asset_versioning.py`. Run pytest and `./bin/e2e`.
Delete `static/`. Update the Dockerfile to the multi-stage form. Update
README / `docs/plan.md` §1 and §9 so they no longer describe unpkg.
After this, a frontend change that is not rebuilt is not deployed. The
multi-stage `Dockerfile` is what builds it on Coolify. Locally the watcher
is what builds it. There is no third path.
## What not to do in this work
- Do not extract Vue components, add Pinia, Vue Router, or TypeScript.
- Do not upgrade Lightweight Charts.
- Do not put a Vite origin in production, or a second public port in compose.
- Do not leave Coolify, add workers, or move env/volume/TLS anywhere else.
- Do not keep `static/` as a fallback once `dist/` is the source of truth.
- Do not add a root `package.json` before phase 1 is live.
- Do not run `npm` from pytest or from the API container.
- Do not ship source maps, serve `frontend/`, or leave `static/` up once
`dist/` is live. Any of those undoes minify.
## Verify
Same commands as today, plus a frontend build:
```bash
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"
./bin/e2e
```
E2E still waits on `window.__chart.bars`. If the canvas is blank, check
`--lang=en-US` before the bundler. If icons are missing, the FA CSS import
did not land. If drawings or the socket die, the page origin changed and
`/ws` is not on the same host.
After the first multi-stage deploy:
```bash
git push && bin/wait-deploy
curl -fsS https://chart.amow.com/api/health
curl -fsS https://chart.amow.com/api/version
```
View-source on `/` should show `/assets/…` with a hash and no unpkg script
tags. The JS behind that URL should be a single minified file with no
`.map`, and fetching `/frontend/src/App.vue` or `/static/app.js` should
404. A hard refresh on a tab that was open across the deploy should pick
up the new JS without a `?v=` rewriter.
## When this is done
`docs/plan.md` §1 currently says "Vue 3 from CDN, **no build step**". That
row becomes the lie the day phase 4 ships — change it in the same commit,
along with §9's script-tag snippet and the README layout line for `static/`.
This file then becomes history, like M0–M10 in the plan.