mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-03 00:11:31 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
Generated
+487
@@ -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",
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"scripts": {
|
||||
"build": "node build.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4",
|
||||
"chart.js": "^4",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user