chart/docs/vite_build.md

14 KiB
Raw Blame History

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:

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):

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.

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.

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:

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:

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:

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:

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:

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.