14 KiB
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.jsstays a plain class. Vue still must not wrap chart or series objects inref()/reactive().window.__chartstays. E2E and diagnostic work depend on it.- One
App.vueholding today's template andsetup(). 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 ontovuefrom npm. ConfluenceChartis already framework-free. It only needsexportinstead ofwindow.ConfluenceChart, and ESM named imports instead of theLightweightChartsglobal.- 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:5173is invisible to them. Whatever serves the UI in dev must still be reachable ashera.local:8010(or whatever host port compose publishes). HMR has to work across that hop, or we do not use HMR. --reloadplus an 82-second seed. Never put a scratch.pyin 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
mainis a production deploy, and a deploy restarts the market stream. The Vite cutover is one of those deploys. Land the production Dockerfile before a rootpackage.jsonexists, or nixpacks may decide this is a Node app and the site goes dark. - E2E hits
http://api:8000, waits onwindow.__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 ischart.addSeries(CandlestickSeries, opts)— the v4 helpers do not exist. - One uvicorn worker, forever, until the streamer is a separate process.
The Dockerfile
CMDis theProcfileline. 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:
/isno-storeand 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 stubdist/used only by that test — do not hitnpmfrom 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.jswith ESM imports andexport. Dropwindow.ConfluenceChart.static/app.jssetup()+ the#appinner HTML →frontend/src/App.vue.import { ConfluenceChart } from './chart.js'. Keep assigningwindow.__chart = chartApiinonMounted.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 oncedist/is the source of truth. - Do not add a root
package.jsonbefore phase 1 is live. - Do not run
npmfrom pytest or from the API container. - Do not ship source maps, serve
frontend/, or leavestatic/up oncedist/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.