356 lines
14 KiB
Markdown
356 lines
14 KiB
Markdown
# 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.
|