Merge pull request #190 from ipnet-mesh/chore/caching-and-bundling

feat: add esbuild bundling with content-hash cache busting
This commit is contained in:
JingleManSweep
2026-05-05 18:31:44 +01:00
committed by GitHub
16 changed files with 1152 additions and 43 deletions
+1
View File
@@ -224,4 +224,5 @@ meshcore.db
# Frontend build artifacts
node_modules/
src/meshcore_hub/web/static/vendor/
src/meshcore_hub/web/static/dist/
src/meshcore_hub/web/static/css/tailwind.css
+1
View File
@@ -47,6 +47,7 @@ COPY alembic.ini ./
# Overlay built frontend assets onto source tree
COPY --from=frontend /app/src/meshcore_hub/web/static/vendor ./src/meshcore_hub/web/static/vendor
COPY --from=frontend /app/src/meshcore_hub/web/static/css/tailwind.css ./src/meshcore_hub/web/static/css/tailwind.css
COPY --from=frontend /app/src/meshcore_hub/web/static/dist ./src/meshcore_hub/web/static/dist
# Build argument for version (set via CI or manually)
ARG BUILD_VERSION=dev
+78 -20
View File
@@ -1,9 +1,18 @@
import { execSync } from "node:child_process";
import { cpSync, mkdirSync, existsSync } from "node:fs";
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
const STATIC = join("src", "meshcore_hub", "web", "static");
const VENDOR = join(STATIC, "vendor");
const DIST = join(STATIC, "dist");
function vendor(pkg, files, dest) {
const out = join(VENDOR, dest);
@@ -26,25 +35,6 @@ execSync(
console.log("Copying vendor files...");
vendor("lit-html", ["lit-html.js", "lit-html.js.map"], "lit-html");
vendor(
"lit-html",
[
"directive.js",
"directive.js.map",
"directive-helpers.js",
"directive-helpers.js.map",
"async-directive.js",
"async-directive.js.map",
],
"lit-html",
);
vendor(
"lit-html",
["directives/unsafe-html.js", "directives/unsafe-html.js.map"],
"lit-html/directives",
);
vendor("leaflet", ["dist/leaflet.css", "dist/leaflet.js", "dist/leaflet.js.map"], "leaflet");
mkdirSync(join(VENDOR, "leaflet", "images"), { recursive: true });
cpSync(
@@ -56,4 +46,72 @@ cpSync(
vendor("chart.js", ["dist/chart.umd.min.js"], "chart.js");
vendor("qrcodejs", ["qrcode.min.js"], "qrcodejs");
console.log("Bundling SPA with esbuild...");
mkdirSync(DIST, { recursive: true });
const metafilePath = join(DIST, "meta.json");
execSync(
`npx esbuild ${join(STATIC, "js", "spa", "app.js")}` +
` --bundle --format=esm --splitting --minify` +
` --outdir=${DIST}` +
` --entry-names=[name].[hash]` +
` --chunk-names=chunks/[name].[hash]` +
` --metafile=${metafilePath}`,
{ stdio: "inherit" },
);
console.log("Generating assets manifest...");
const meta = JSON.parse(readFileSync(metafilePath, "utf-8"));
const assets = {};
for (const [outputPath, info] of Object.entries(meta.outputs)) {
if (!info.entryPoint) continue;
const entryName = info.entryPoint.split("/").pop().replace(/\.js$/, ".js");
const fileName = outputPath.split("/").pop();
assets[entryName] = fileName;
}
const vendorFiles = {
"leaflet.css": join(VENDOR, "leaflet", "leaflet.css"),
"leaflet.js": join(VENDOR, "leaflet", "leaflet.js"),
"chart.umd.min.js": join(VENDOR, "chart.js", "chart.umd.min.js"),
"qrcode.min.js": join(VENDOR, "qrcodejs", "qrcode.min.js"),
};
const vendorHashes = {};
for (const [name, path] of Object.entries(vendorFiles)) {
if (existsSync(path)) {
const hash = createHash("sha256")
.update(readFileSync(path))
.digest("hex")
.slice(0, 8);
vendorHashes[name] = hash;
}
}
const localesDir = join(STATIC, "locales");
let localeContent = "";
if (existsSync(localesDir)) {
const localeFiles = readdirSync(localesDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const f of localeFiles) {
localeContent += readFileSync(join(localesDir, f), "utf-8");
}
}
const localeVersion =
localeContent.length > 0
? createHash("sha256").update(localeContent).digest("hex").slice(0, 8)
: "";
const manifest = {
...assets,
vendor: vendorHashes,
locale_version: localeVersion,
};
writeFileSync(join(DIST, "assets.json"), JSON.stringify(manifest, null, 2));
console.log(" Manifest:", JSON.stringify(manifest, null, 2));
console.log("Done.");
@@ -0,0 +1,288 @@
# Plan: esbuild Bundling + Cache Busting for Static Assets
**Date:** 2025-05-05
**Status:** Draft
## Problem
Only 4 of ~30+ static assets have cache-busting query parameters. ES module sub-imports (`components.js`, `router.js`, `i18n.js`, etc.) and all vendor libraries are cached at most 1 hour with no invalidation mechanism. The `?v={{ version }}` on `app.js` is the only entry into the ES module graph, but browsers strip query parameters when resolving relative imports -- so the entire module tree loads unversioned.
### Current state
| Asset | Cache busting | Cache-Control |
|-------|--------------|---------------|
| `tailwind.css` | `?v={{ version }}` | `immutable` (1 year) |
| `app.css` | `?v={{ version }}` | `immutable` (1 year) |
| `charts.js` | `?v={{ version }}` | `immutable` (1 year) |
| `app.js` (SPA entry) | `?v={{ version }}` | `immutable` (1 year) |
| All other SPA modules (~14 files) | None | 1 hour |
| All page modules (11 files) | None | 1 hour |
| lit-html (via import map) | None | 1 hour |
| Leaflet CSS/JS | None | 1 hour |
| Chart.js, QRCode.js | None | 1 hour |
| Locale JSON files | None | 1 hour |
| Static images (logo, meshcore) | None | 1 hour |
## Solution
Use **esbuild** to bundle and minify the SPA JavaScript, generating content-hashed filenames for automatic cache invalidation. Add `?v=` cache busting to vendor libs that remain as global `<script>` tags.
### Why esbuild
| Factor | esbuild | Rollup | Terser-only | Native fix |
|--------|---------|--------|-------------|------------|
| Content hashing | Built-in | Plugin needed | Manual | Manual + complex |
| Dynamic import splitting | `--splitting` | Yes | N/A | Breaks |
| Tree-shaking | Yes | Best | No | No |
| Minification | Built-in | Plugin | Yes | No |
| New deps | 1 package (~10 MB) | 3+ (~15 MB) | 1 (~3 MB) | 0 |
| Config | CLI flags (1 line) | Config file | build.js loop | Half a bundler |
| Import map | Eliminated (bundles lit-html) | Same | Keeps | Keeps |
- The project already uses Node.js for builds (Tailwind CLI, vendor copying)
- SPA JS is ~195 KB unminified across 18 files -- esbuild processes it in <50ms
- lit-html (~10 KB) gets bundled in, eliminating the import map
- Dynamic imports preserved via `--splitting` (each page stays a separate chunk)
- Vendor IIFE libs (Leaflet, Chart.js, QRCode.js) stay as-is
### Estimated impact
| Metric | Current | After |
|--------|---------|-------|
| SPA JS (initial load) | ~42 KB unminified (7+ files) | ~18-20 KB minified (1-2 chunks) |
| SPA JS (total) | ~195 KB unminified | ~80-90 KB minified |
| HTTP requests (initial) | 7+ JS files | 1-2 JS files |
| Cache invalidation | Version-based (all-or-nothing) | Content-hash (per-file) |
## Implementation
### Step 1: Add esbuild to build pipeline
**File:** `package.json`
Add `esbuild` as a dev dependency:
```json
"devDependencies": {
"esbuild": "^0.25"
}
```
> Note: esbuild is only needed at build time. In Docker, it's installed via `npm ci` in the frontend stage and discarded. It could alternatively run via `npx esbuild` without adding to `package.json`, but listing it explicitly ensures version pinning via `package-lock.json`.
**File:** `build.js`
Add an esbuild step after the Tailwind build:
1. Define a `DIST` output directory: `src/meshcore_hub/web/static/dist/`
2. Run esbuild via `execSync`:
```
npx esbuild src/meshcore_hub/web/static/js/spa/app.js
--bundle --format=esm --splitting --minify
--outdir=src/meshcore_hub/web/static/dist
--entry-names=[name].[hash].js
--chunk-names=chunks/[name].[hash].js
--metafile=src/meshcore_hub/web/static/dist/meta.json
```
- `--splitting`: preserves dynamic `import()` as separate chunks
- `--format=esm`: output ES modules
- `--minify`: minify all output
- `--entry-names=[name].[hash].js`: content-hashed filenames for entry points
- `--chunk-names=chunks/[name].[hash].js`: content-hashed filenames for shared chunks
- `--metafile`: generates a JSON manifest mapping inputs to outputs
3. Post-process `metafile` to generate a simplified `assets.json` manifest:
```json
{
"app.js": "app.abc123.js",
"chunks/shared.def456.js": "chunks/shared.def456.js"
}
```
4. Compute SHA256 content hashes (first 8 hex chars) for vendor files (Leaflet, Chart.js, QRCode.js, their CSS) and all locale JSON files combined. Add to `assets.json`:
```json
{
"app.js": "app.abc123.js",
"vendor": {
"leaflet.css": "a1b2c3",
"leaflet.js": "d4e5f6",
"chart.umd.min.js": "g7h8i9",
"qrcode.min.js": "j0k1l2"
},
"locale_version": "m3n4o5"
}
```
**Vendor hashes** are computed per-file using `crypto.createHash("sha256")`,
reading each file's contents. **`locale_version`** is computed by hashing the
concatenated contents of ALL locale JSON files in the `locales/` directory
(sorted by filename for determinism) — any locale change invalidates all
locale caches. This value is passed to the SPA via `_build_config_json()` so
`i18n.js` can append `?v=` to its fetch URL.
5. Remove the lit-html vendor copy step (lines 29-46 of `build.js`) since esbuild bundles it from `node_modules`.
**File:** `.gitignore`
Add the `dist/` output directory:
```
src/meshcore_hub/web/static/dist/
```
### Step 2: Python manifest loader
**File:** `src/meshcore_hub/web/app.py`
1. Add a `_load_asset_manifest()` function that reads `assets.json` from `STATIC_DIR / "dist" / "assets.json"` at startup. Returns an empty dict if the file doesn't exist (for bare source installs without a build step).
2. Store the manifest on `app.state.asset_manifest` during `create_app()`.
3. Pass asset paths to the Jinja2 template context:
- `asset_app_js`: the hashed app entry point (e.g., `"app.abc123.js"`)
- `vendor_hashes`: dict of vendor file hashes (e.g., `{"leaflet.css": "a1b2c3", ...}`)
4. The `_build_config_json()` function should include `locale_version` from the manifest so the SPA's `i18n.js` can append it to locale fetch URLs.
### Step 3: Update spa.html template
**File:** `src/meshcore_hub/web/templates/spa.html`
1. **Remove** the `<script type="importmap">` block (lines 49-57). lit-html is now bundled by esbuild.
2. **Update** the SPA entry point (line 210):
```html
<!-- Before -->
<script type="module" src="/static/js/spa/app.js?v={{ version }}"></script>
<!-- After -->
<script type="module" src="/static/dist/{{ asset_app_js }}"></script>
```
3. **Add** `?v=` cache busting to vendor `<script>` and `<link>` tags:
```html
<!-- Leaflet CSS -->
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css?v={{ vendor_hashes['leaflet.css'] }}" />
<!-- Leaflet JS -->
<script src="/static/vendor/leaflet/leaflet.js?v={{ vendor_hashes['leaflet.js'] }}"></script>
<!-- Chart.js -->
<script src="/static/vendor/chart.js/chart.umd.min.js?v={{ vendor_hashes['chart.umd.min.js'] }}"></script>
<!-- QRCode.js -->
<script src="/static/vendor/qrcodejs/qrcode.min.js?v={{ vendor_hashes['qrcode.min.js'] }}"></script>
```
4. `charts.js` remains unchanged (it already has `?v={{ version }}`, which is fine since it changes with releases).
5. **Graceful fallback**: If `asset_app_js` is empty (no `dist/` directory), fall back to the original `app.js?v={{ version }}` path. This ensures source installs without `npm run build` still work.
### Step 4: Update i18n.js locale fetching
**File:** `src/meshcore_hub/web/static/js/spa/i18n.js`
The locale JSON fetch at line 23 needs a cache-busting parameter. Since this file is now bundled by esbuild, the version string must come from the embedded `window.__APP_CONFIG__`:
```javascript
// Before
const res = await fetch(`/static/locales/${locale}.json`);
// After
const config = window.__APP_CONFIG__ || {};
const v = config.locale_version || '';
const res = await fetch(`/static/locales/${locale}.json${v ? '?v=' + v : ''}`);
```
The `locale_version` value is set by `build.js` (content hash of all locale files combined) and passed through the Python config JSON.
### Step 5: Update static image references
**File:** `src/meshcore_hub/web/static/js/spa/pages/home.js`
Two hardcoded image references without cache busting:
- Line ~168: `'/static/img/logo.svg'`
- Line ~221: `"/static/img/meshcore.svg"`
These are embedded in lit-html templates and rarely change. Options:
- **(A)** Include image hashes in the manifest and reference them via `window.__APP_CONFIG__` -- adds complexity for minimal benefit.
- **(B)** Leave as-is with the existing 1-hour cache -- images rarely change and are small.
**Recommendation:** Option B. Images are small SVGs that rarely change. The 1-hour cache is acceptable.
### Step 6: Update Dockerfile
**File:** `Dockerfile`
Add a `COPY --from=frontend` line to overlay the `dist/` directory:
```dockerfile
# Overlay built frontend assets onto source tree
COPY --from=frontend /app/src/meshcore_hub/web/static/vendor ./src/meshcore_hub/web/static/vendor
COPY --from=frontend /app/src/meshcore_hub/web/static/css/tailwind.css ./src/meshcore_hub/web/static/css/tailwind.css
COPY --from=frontend /app/src/meshcore_hub/web/static/dist ./src/meshcore_hub/web/static/dist
```
The `dist/` directory is generated by `npm run build` in the frontend stage and includes:
- `app.[hash].js` (SPA entry point)
- `chunks/` directory (shared chunks, page modules)
- `assets.json` (manifest for Python to read)
- `meta.json` (esbuild metafile, optional -- could be excluded from Docker image)
### Step 7: Remove vendored lit-html
**Files to remove:** `src/meshcore_hub/web/static/vendor/lit-html/` (entire directory)
This directory is already gitignored (line 226 of `.gitignore`). The lit-html vendor copy step in `build.js` (lines 29-46) is removed in Step 1.
## Middleware impact
**File:** `src/meshcore_hub/web/middleware.py`
The `/static/dist/` directory serves content-hashed files (the filename itself is the cache-buster — when content changes, the filename changes). These need `immutable` caching:
```python
# Static dist/ files use content-hashed filenames — immutable
elif path.startswith("/static/dist/"):
response.headers["cache-control"] = "public, max-age=31536000, immutable"
```
Add this rule **before** the generic `/static/` rule (line 50-51) so it takes priority. Placement:
1. `/health` → `no-cache` *(unchanged)*
2. `/static/` + `v=` → `immutable` *(unchanged)*
3. **`/static/dist/` → `immutable`** *(new)*
4. `/static/` → `1-hour` *(unchanged)*
The `?v=` params on vendor files (e.g., `/static/vendor/leaflet/leaflet.css?v=hash`) already match rule 2 and get `immutable`. No changes needed for the vendor path pattern.
## Development workflow
- `npm run build` is required after any JS change to see it reflected
- Source files in `js/spa/` remain the source of truth
- `dist/` is a build artifact (gitignored)
- For CSS-only changes, only the Tailwind build runs (esbuild step is fast enough that running it is harmless)
## Files changed
| File | Change |
|------|--------|
| `package.json` | Add `esbuild` to devDependencies |
| `build.js` | Add esbuild step, generate manifest, remove lit-html vendor copy |
| `.gitignore` | Add `src/meshcore_hub/web/static/dist/` |
| `Dockerfile` | Add `COPY --from=frontend` for `dist/` directory |
| `src/meshcore_hub/web/app.py` | Add manifest loader, pass asset paths to template |
| `src/meshcore_hub/web/templates/spa.html` | Remove import map, use hashed bundle path, add vendor `?v=` |
| `src/meshcore_hub/web/middleware.py` | Add `/static/dist/` immutable cache rule |
| `src/meshcore_hub/web/static/js/spa/i18n.js` | Add `?v=` to locale fetch URL |
## Risks and mitigations
| Risk | Mitigation |
|------|------------|
| Source install without `npm run build` breaks | Fallback in `spa.html`: if `asset_app_js` is empty, use original `app.js?v={{ version }}` path |
| esbuild `--splitting` requires HTTP/2 for optimal loading | Already the case with current native ES modules (multiple parallel requests) |
| Dynamic import paths change after bundling | esbuild handles this automatically -- `import('./pages/home.js')` becomes `import('./chunks/home.abc123.js')` in the output |
| lit-html import map removal breaks third-party extensions | Import map was only used internally. No external consumers. |
| Build step required for every JS change during development | Accepted trade-off. esbuild is <50ms. `npm run build` is already required for Tailwind. |
## Future considerations
- The `charts.js` file could be converted to an ES module and imported by the dashboard page module, eliminating the need for a separate `<script>` tag and the global `Chart` dependency.
- Vendor libs (Leaflet, Chart.js, QRCode.js) could be converted to ES module imports in the pages that use them, allowing esbuild to tree-shake unused code. This is a larger refactor.
- The `assets.json` manifest could be extended to include CSS hashes, replacing the `?v={{ version }}` on `tailwind.css` and `app.css` with content hashes.
@@ -0,0 +1,229 @@
# Tasks: esbuild Bundling + Cache Busting
Reference: [plan.md](./plan.md)
## Phase 1: Build pipeline
### Task 1.1 — Add esbuild dependency
**File:** `package.json`
- Add `"esbuild": "^0.25"` to `devDependencies`
```json
"devDependencies": {
"esbuild": "^0.25"
}
```
### Task 1.2 — Update build.js with esbuild step
**File:** `build.js`
1. Add imports: `readFileSync`, `writeFileSync`, `readdirSync` from `node:fs`; `createHash` from `node:crypto`
2. Define `DIST` constant: `join(STATIC, "dist")`
3. After Tailwind build (line 25), add esbuild step:
- `mkdirSync(DIST, { recursive: true })`
- Run esbuild via `execSync`:
```
npx esbuild src/meshcore_hub/web/static/js/spa/app.js
--bundle --format=esm --splitting --minify
--outdir=src/meshcore_hub/web/static/dist
--entry-names=[name].[hash].js
--chunk-names=chunks/[name].[hash].js
--metafile=src/meshcore_hub/web/static/dist/meta.json
```
4. Post-process `meta.json` → `assets.json`:
- Parse `meta.json`, extract `outputs` → simplify to `{ "app.js": "app.abc123.js", ... }`
5. Compute vendor hashes:
- For each vendor file (`leaflet.css`, `leaflet.js`, `chart.umd.min.js`, `qrcode.min.js`):
`createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 8)`
- Add to `assets.json` under `"vendor"` key
6. Compute `locale_version`:
- Read all `*.json` files from `join(STATIC, "locales")`, sorted by filename
- Concatenate contents, hash with SHA256, take first 8 hex chars
- Add to `assets.json` as `"locale_version"`
7. Write `assets.json` to `join(DIST, "assets.json")`
8. **Remove** lit-html vendor copy block (lines 29-46)
### Task 1.3 — Update .gitignore
**File:** `.gitignore`
Add after line 226 (`src/meshcore_hub/web/static/vendor/`):
```
src/meshcore_hub/web/static/dist/
```
---
## Phase 2: Python changes
### Task 2.1 — Add manifest loader to app.py
**File:** `src/meshcore_hub/web/app.py`
1. Add `_load_asset_manifest()` helper:
```python
def _load_asset_manifest() -> dict[str, Any]:
manifest_path = STATIC_DIR / "dist" / "assets.json"
if not manifest_path.exists():
return {}
try:
return json.loads(manifest_path.read_text())
except (OSError, json.JSONDecodeError):
return {}
```
2. In `create_app()`, call manifest and store on `app.state`:
```python
manifest = _load_asset_manifest()
app.state.asset_manifest = manifest
```
3. Extract convenience values:
```python
app.state.asset_app_js = manifest.get("app.js", "")
app.state.vendor_hashes = manifest.get("vendor", {})
app.state.locale_version = manifest.get("locale_version", "")
```
4. In `_build_config_json()`, add to the `config` dict (after line 273):
```python
"locale_version": getattr(app.state, "locale_version", ""),
```
### Task 2.2 — Pass asset paths to spa.html template
**File:** `src/meshcore_hub/web/app.py`
In the `spa_catchall` handler (line 1036), add to the template context dict:
```python
"asset_app_js": request.app.state.asset_app_js,
"vendor_hashes": request.app.state.vendor_hashes,
```
### Task 2.3 — Add /static/dist/ immutable cache rule
**File:** `src/meshcore_hub/web/middleware.py`
Insert a new `elif` block between the existing `/static/` + `v=` rule (line 50-51) and the generic `/static/` rule (line 53-55):
```python
# Static dist/ files use content-hashed filenames — immutable
elif path.startswith("/static/dist/"):
response.headers["cache-control"] = "public, max-age=31536000, immutable"
```
Final rule order:
1. `/health` → `no-cache` (line 46-47, unchanged)
2. `/static/` + `v=` → `immutable` (line 50-51, unchanged)
3. `/static/dist/` → `immutable` (**new**)
4. `/static/` → `1-hour` (line 53-55, unchanged)
---
## Phase 3: Template and frontend
### Task 3.1 — Update spa.html template
**File:** `src/meshcore_hub/web/templates/spa.html`
1. **Remove** the import map block (lines 49-57):
```html
<script type="importmap">
{
"imports": {
"lit-html": "/static/vendor/lit-html/lit-html.js",
"lit-html/": "/static/vendor/lit-html/"
}
}
</script>
```
2. **Update** vendor tags to add `?v=` cache busting:
- Line 44: `<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css?v={{ vendor_hashes['leaflet.css'] }}" />`
- Line 177: `<script src="/static/vendor/leaflet/leaflet.js?v={{ vendor_hashes['leaflet.js'] }}"></script>`
- Line 180: `<script src="/static/vendor/chart.js/chart.umd.min.js?v={{ vendor_hashes['chart.umd.min.js'] }}"></script>`
- Line 183: `<script src="/static/vendor/qrcodejs/qrcode.min.js?v={{ vendor_hashes['qrcode.min.js'] }}"></script>`
3. **Update** SPA entry point (line 210):
```html
{% if asset_app_js %}
<script type="module" src="/static/dist/{{ asset_app_js }}"></script>
{% else %}
<script type="module" src="/static/js/spa/app.js?v={{ version }}"></script>
{% endif %}
```
4. `charts.js` (line 186) stays unchanged — already has `?v={{ version }}`.
### Task 3.2 — Update i18n.js locale fetching
**File:** `src/meshcore_hub/web/static/js/spa/i18n.js`
Update line 23:
```javascript
// Before
const res = await fetch(`/static/locales/${locale}.json`);
// After
const config = window.__APP_CONFIG__ || {};
const v = config.locale_version || '';
const res = await fetch(`/static/locales/${locale}.json${v ? '?v=' + v : ''}`);
```
Note: `window.__APP_CONFIG__` is already set by the inline `<script>` in spa.html that parses `config_json`.
---
## Phase 4: Docker and cleanup
### Task 4.1 — Update Dockerfile
**File:** `Dockerfile`
Add after line 49 (`COPY --from=frontend ... tailwind.css`):
```dockerfile
COPY --from=frontend /app/src/meshcore_hub/web/static/dist ./src/meshcore_hub/web/static/dist
```
This overlays the bundled JS (`app.[hash].js`, `chunks/`, `assets.json`) onto the source tree before `pip install`.
### Task 4.2 — Remove lit-html vendor copy from build
**File:** `build.js`
Already covered in Task 1.2 step 8 — remove lines 29-46 (the `vendor("lit-html", ...)` calls).
No separate action needed.
---
## Phase 5: Verify
### Task 5.1 — Build and verify
1. `npm install` — installs esbuild
2. `npm run build` — should produce:
- `src/meshcore_hub/web/static/dist/app.[hash].js`
- `src/meshcore_hub/web/static/dist/chunks/[name].[hash].js` (shared chunks)
- `src/meshcore_hub/web/static/dist/assets.json`
- `src/meshcore_hub/web/static/dist/meta.json`
3. Verify `assets.json` contains `app.js`, `vendor`, and `locale_version` keys
4. `meshcore-hub web` — dashboard should load with bundled JS
5. Check browser DevTools Network tab:
- `/static/dist/app.*.js` → `Cache-Control: public, max-age=31536000, immutable`
- `/static/vendor/leaflet/leaflet.css?v=*` → `Cache-Control: public, max-age=31536000, immutable`
- `/static/locales/en.json?v=*` → `Cache-Control: public, max-age=31536000, immutable`
### Task 5.2 — Fallback test
1. Delete `dist/` directory
2. `meshcore-hub web` — dashboard should load using `app.js?v={{ version }}` fallback
3. Verify no errors in browser console
### Task 5.3 — Run tests
```bash
source .venv/bin/activate
pytest tests/test_web/ -v
pre-commit run --all-files
```
+487
View File
@@ -12,6 +12,451 @@
"lit-html": "^3",
"qrcodejs": "^1.0.0",
"tailwindcss": "^4"
},
"devDependencies": {
"esbuild": "^0.25"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@jridgewell/gen-mapping": {
@@ -740,6 +1185,48 @@
"node": ">=10.13.0"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+3
View File
@@ -4,6 +4,9 @@
"scripts": {
"build": "node build.js"
},
"devDependencies": {
"esbuild": "^0.25"
},
"dependencies": {
"@tailwindcss/cli": "^4",
"chart.js": "^4",
+27
View File
@@ -41,6 +41,23 @@ TEMPLATES_DIR = PACKAGE_DIR / "templates"
STATIC_DIR = PACKAGE_DIR / "static"
def _load_asset_manifest() -> dict[str, Any]:
"""Load the esbuild asset manifest from dist/assets.json.
Returns:
Manifest dict with entry names, vendor hashes, and locale version.
Returns empty dict if manifest is missing or invalid.
"""
manifest_path = STATIC_DIR / "dist" / "assets.json"
if not manifest_path.exists():
return {}
try:
data: dict[str, Any] = json.loads(manifest_path.read_text())
return data
except (OSError, json.JSONDecodeError):
return {}
# Per-endpoint, per-method role access mapping for the API proxy.
# Key: URL path prefix (after /api/), Value: {method -> allowed roles}.
# _OPEN = unconditional access (OIDC on or off, anonymous OK).
@@ -271,6 +288,7 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
"channel_labels": app.state.channel_labels,
"logo_invert_light": app.state.logo_invert_light,
"debug": app.state.web_debug,
"locale_version": getattr(app.state, "locale_version", ""),
}
role_names = {
@@ -535,6 +553,13 @@ def create_app(
page_loader.load_pages()
app.state.page_loader = page_loader
# Load esbuild asset manifest for cache-busted filenames
manifest = _load_asset_manifest()
app.state.asset_manifest = manifest
app.state.asset_app_js = manifest.get("app.js", "")
app.state.vendor_hashes = manifest.get("vendor", {})
app.state.locale_version = manifest.get("locale_version", "")
# Check for custom logo and store media path
media_home = Path(settings.effective_media_home)
logo_url, logo_invert_light, logo_path = _resolve_logo(media_home)
@@ -1065,6 +1090,8 @@ def create_app(
"version": __version__,
"default_theme": request.app.state.web_theme,
"config_json": config_json,
"asset_app_js": request.app.state.asset_app_js,
"vendor_hashes": request.app.state.vendor_hashes,
},
)
+4
View File
@@ -50,6 +50,10 @@ class CacheControlMiddleware(BaseHTTPMiddleware):
elif path.startswith("/static/") and "v=" in query_params:
response.headers["cache-control"] = "public, max-age=31536000, immutable"
# Static dist/ files use content-hashed filenames — immutable
elif path.startswith("/static/dist/"):
response.headers["cache-control"] = "public, max-age=31536000, immutable"
# Static files without version - short cache as fallback
elif path.startswith("/static/"):
response.headers["cache-control"] = "public, max-age=3600"
+3 -1
View File
@@ -20,7 +20,9 @@ let _locale = 'en';
*/
export async function loadLocale(locale) {
try {
const res = await fetch(`/static/locales/${locale}.json`);
const config = window.__APP_CONFIG__ || {};
const v = config.locale_version || '';
const res = await fetch(`/static/locales/${locale}.json${v ? '?v=' + v : ''}`);
if (res.ok) {
_translations = await res.json();
_locale = locale;
+8 -14
View File
@@ -41,20 +41,10 @@
<link rel="stylesheet" href="/static/css/tailwind.css?v={{ version }}" />
<!-- Leaflet CSS for maps -->
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css" />
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css?v={{ vendor_hashes['leaflet.css'] }}" />
<!-- Custom application styles -->
<link rel="stylesheet" href="/static/css/app.css?v={{ version }}">
<!-- Import map for ES module dependencies -->
<script type="importmap">
{
"imports": {
"lit-html": "/static/vendor/lit-html/lit-html.js",
"lit-html/": "/static/vendor/lit-html/"
}
}
</script>
</head>
<body class="min-h-screen bg-base-200 flex flex-col">
<!-- Navbar -->
@@ -174,13 +164,13 @@
</footer>
<!-- Leaflet JS for maps -->
<script src="/static/vendor/leaflet/leaflet.js"></script>
<script src="/static/vendor/leaflet/leaflet.js?v={{ vendor_hashes['leaflet.js'] }}"></script>
<!-- Chart.js for charts -->
<script src="/static/vendor/chart.js/chart.umd.min.js"></script>
<script src="/static/vendor/chart.js/chart.umd.min.js?v={{ vendor_hashes['chart.umd.min.js'] }}"></script>
<!-- QR Code library -->
<script src="/static/vendor/qrcodejs/qrcode.min.js"></script>
<script src="/static/vendor/qrcodejs/qrcode.min.js?v={{ vendor_hashes['qrcode.min.js'] }}"></script>
<!-- Chart helper functions -->
<script src="/static/js/charts.js?v={{ version }}"></script>
@@ -207,6 +197,10 @@
</script>
<!-- SPA Application (ES Module) -->
{% if asset_app_js %}
<script type="module" src="/static/dist/{{ asset_app_js }}"></script>
{% else %}
<script type="module" src="/static/js/spa/app.js?v={{ version }}"></script>
{% endif %}
</body>
</html>
+3 -1
View File
@@ -31,7 +31,9 @@ class TestAdvertisementsPage:
def test_advertisements_contains_spa_script(self, client: TestClient) -> None:
"""Test that advertisements page includes SPA application script."""
response = client.get("/advertisements")
assert "/static/js/spa/app.js" in response.text
has_bundled = "/static/dist/" in response.text
has_fallback = "/static/js/spa/app.js" in response.text
assert has_bundled or has_fallback
class TestAdvertisementsPageFilters:
+11 -4
View File
@@ -151,17 +151,24 @@ class TestVersionParameterInHTML:
assert f"?v={__version__}" in charts_script["src"]
def test_app_js_has_version(self, client):
"""SPA app.js script should include version parameter."""
"""SPA app.js script should include version or content hash."""
response = client.get("/")
assert response.status_code == 200
soup = BeautifulSoup(response.text, "html.parser")
app_script = soup.find(
bundled_script = soup.find(
"script",
{"src": lambda x: x and "/static/dist/" in x and x.endswith(".js")},
)
fallback_script = soup.find(
"script", {"src": lambda x: x and "/static/js/spa/app.js" in x}
)
assert app_script is not None
assert f"?v={__version__}" in app_script["src"]
if bundled_script:
assert "/static/dist/" in bundled_script["src"]
else:
assert fallback_script is not None
assert f"?v={__version__}" in fallback_script["src"]
def test_cdn_resources_unchanged(self, client):
"""CDN resources should not have version parameters."""
+3 -1
View File
@@ -86,4 +86,6 @@ class TestHomePage:
def test_home_contains_spa_app_script(self, client: TestClient) -> None:
"""Test that home page includes the SPA application script."""
response = client.get("/")
assert "/static/js/spa/app.js" in response.text
has_bundled = "/static/dist/" in response.text
has_fallback = "/static/js/spa/app.js" in response.text
assert has_bundled or has_fallback
+3 -1
View File
@@ -31,7 +31,9 @@ class TestMessagesPage:
def test_messages_contains_spa_script(self, client: TestClient) -> None:
"""Test that messages page includes SPA application script."""
response = client.get("/messages")
assert "/static/js/spa/app.js" in response.text
has_bundled = "/static/dist/" in response.text
has_fallback = "/static/js/spa/app.js" in response.text
assert has_bundled or has_fallback
class TestMessagesPageFilters:
+3 -1
View File
@@ -31,7 +31,9 @@ class TestNodesListPage:
def test_nodes_contains_spa_script(self, client: TestClient) -> None:
"""Test that nodes page includes SPA application script."""
response = client.get("/nodes")
assert "/static/js/spa/app.js" in response.text
has_bundled = "/static/dist/" in response.text
has_fallback = "/static/js/spa/app.js" in response.text
assert has_bundled or has_fallback
def test_nodes_with_search_param(self, client: TestClient) -> None:
"""Test nodes page with search parameter returns SPA shell."""