From 8e700fb98a0846f61eec583e85fa7aec7f870256 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 18:29:25 +0000 Subject: [PATCH] [feat](trx-frontend-http): optimize HTTP frontend performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side: - Cache index_html() with OnceLock (avoids 3 string replacements per request) - Pre-compress all static assets (JS/CSS/HTML) with gzip at startup, serve cached bytes with ETag + Cache-Control headers for browser caching - Add If-None-Match / 304 Not Modified support for conditional GETs - Serialize SSE state+meta in single serde pass via SnapshotWithMeta, eliminating the serialize → parse → flatten → re-serialize round-trip - Add Cache-Control: immutable for favicon/logo (never change) Client-side: - Replace atob() + charCodeAt loop with direct base64 lookup-table decoder that writes to a reusable Int8Array (avoids UTF-16 string allocation) - Spectrum bins now flow as Int8Array throughout the pipeline, reducing waterfall row memory from ~8 bytes/element to 1 byte/element - Add isBinsArray() helper to support both Array and TypedArray in all spectrum/waterfall guard checks https://claude.ai/code/session_01J3VCWZeEPsyFJiHjJRBREo Signed-off-by: Claude --- .../trx-frontend-http/assets/web/app.js | 75 +++-- .../trx-frontend/trx-frontend-http/src/api.rs | 295 +++++++++++------- .../trx-frontend-http/src/status.rs | 25 +- 3 files changed, 256 insertions(+), 139 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 7a6089b..3111ffe 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -1440,7 +1440,7 @@ function overviewVisibleBinWindow(data, binCount) { } function pushOverviewWaterfallFrame(data) { - if (!overviewCanvas || !data || !Array.isArray(data.bins) || data.bins.length === 0) return; + if (!overviewCanvas || !data || !isBinsArray(data.bins) || data.bins.length === 0) return; overviewWaterfallRows.push(data.bins.slice()); overviewWaterfallPushCount++; trimOverviewWaterfallRows(); @@ -1502,7 +1502,7 @@ function drawOverviewWaterfall(W, H, pal) { ensureWaterfallLut(pal, minDb, maxDb); function renderRow(dstY, srcBins) { - if (!Array.isArray(srcBins) || srcBins.length === 0) return; + if (!isBinsArray(srcBins) || srcBins.length === 0) return; const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length); const spanBins = Math.max(1, endIdx - startIdx); const rowBase = dstY * rowStride; @@ -2200,7 +2200,7 @@ function setRigFrequency(freqHz) { } function spectrumBinIndexForHz(data, hz) { - if (!data || !Array.isArray(data.bins) || data.bins.length < 2 || !Number.isFinite(hz)) { + if (!data || !isBinsArray(data.bins) || data.bins.length < 2 || !Number.isFinite(hz)) { return null; } const maxIdx = data.bins.length - 1; @@ -2216,7 +2216,7 @@ function spectrumPowerScore(db) { } function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { - if (!data || !Array.isArray(data.bins) || data.bins.length < 16) { + if (!data || !isBinsArray(data.bins) || data.bins.length < 16) { return null; } if (!Number.isFinite(freqHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { @@ -4167,7 +4167,7 @@ async function applyBandwidthFromInput() { } function estimateBandwidthAroundPeak(data, centerHz) { - if (!data || !Array.isArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) { + if (!data || !isBinsArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) { return null; } @@ -7976,7 +7976,7 @@ if (sdrSquelchAutoBtn) { if (!sdrSquelchSupported) return; let pct = 0; // default: Off const data = lastSpectrumData || window.lastSpectrumData; - if (data && Array.isArray(data.bins) && data.bins.length > 0) { + if (data && isBinsArray(data.bins) && data.bins.length > 0) { const noiseDb = estimateNoiseFloorDb(data.bins); if (noiseDb != null && Number.isFinite(noiseDb)) { // Set threshold slightly above noise floor so squelch closes on noise @@ -9023,6 +9023,37 @@ let waterfallGamma = 1.0; const SPECTRUM_HEADROOM_DB = 20; const SPECTRUM_SMOOTH_ALPHA = 0.42; let _spectrumBinBuf = []; // Reusable buffer for SSE bin decoding +// Fast base64 → Int8Array decoder using a lookup table. +// Avoids atob() (which allocates a UTF-16 string) and the subsequent +// charCodeAt loop, decoding directly into a reusable typed array. +const _b64Lut = new Uint8Array(128); +for (let i = 0; i < 128; i++) _b64Lut[i] = 255; +"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").forEach((c, i) => { + _b64Lut[c.charCodeAt(0)] = i; +}); +let _spectrumBinI8 = new Int8Array(0); // Reusable typed-array bin buffer +// Check if a value is an array-like bins buffer (Array or TypedArray). +function isBinsArray(v) { return Array.isArray(v) || ArrayBuffer.isView(v); } +function decodeBase64ToInt8(b64) { + // Strip trailing '=' padding + let end = b64.length; + while (end > 0 && b64.charCodeAt(end - 1) === 61) end--; + const outLen = (end * 3 >>> 2); // exact byte count without padding + if (_spectrumBinI8.length !== outLen) _spectrumBinI8 = new Int8Array(outLen); + const out = _spectrumBinI8; + let j = 0; + for (let i = 0; i < end; ) { + const a = _b64Lut[b64.charCodeAt(i++)]; + const b = i < end ? _b64Lut[b64.charCodeAt(i++)] : 0; + const c = i < end ? _b64Lut[b64.charCodeAt(i++)] : 0; + const d = i < end ? _b64Lut[b64.charCodeAt(i++)] : 0; + const n = (a << 18) | (b << 12) | (c << 6) | d; + if (j < outLen) out[j++] = (n >> 16) & 0xff; + if (j < outLen) out[j++] = (n >> 8) & 0xff; + if (j < outLen) out[j++] = n & 0xff; + } + return out; +} // Crosshair state (CSS coords relative to spectrum canvas). let spectrumCrosshairX = null; @@ -9115,14 +9146,14 @@ function pruneSpectrumPeakHoldFrames(now = Date.now()) { let removeCount = 0; for (let i = 0; i < spectrumPeakHoldFrames.length; i++) { const f = spectrumPeakHoldFrames[i]; - if (f && Array.isArray(f.bins) && now - f.t <= holdMs) break; + if (f && isBinsArray(f.bins) && now - f.t <= holdMs) break; removeCount++; } if (removeCount > 0) spectrumPeakHoldFrames.splice(0, removeCount); } function pushSpectrumPeakHoldFrame(frame) { - if (!frame || !Array.isArray(frame.bins) || frame.bins.length === 0) { + if (!frame || !isBinsArray(frame.bins) || frame.bins.length === 0) { clearSpectrumPeakHoldFrames(); return; } @@ -9142,14 +9173,14 @@ function pushSpectrumPeakHoldFrame(frame) { function buildSpectrumPeakHoldBins(currentBins) { const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); - if (holdMs <= 0 || !Array.isArray(currentBins) || currentBins.length === 0) { + if (holdMs <= 0 || !isBinsArray(currentBins) || currentBins.length === 0) { return null; } pruneSpectrumPeakHoldFrames(); if (spectrumPeakHoldFrames.length === 0) return null; const peakBins = currentBins.slice(); for (const frame of spectrumPeakHoldFrames) { - if (!frame || !Array.isArray(frame.bins) || frame.bins.length !== peakBins.length) continue; + if (!frame || !isBinsArray(frame.bins) || frame.bins.length !== peakBins.length) continue; for (let i = 0; i < peakBins.length; i++) { if (frame.bins[i] > peakBins[i]) peakBins[i] = frame.bins[i]; } @@ -9160,7 +9191,7 @@ function buildSpectrumPeakHoldBins(currentBins) { // Estimate noise floor as the 15th-percentile of visible bins (same heuristic as Auto). // Uses O(N) nth-element selection instead of O(N log N) sort. function estimateNoiseFloorDb(bins) { - if (!Array.isArray(bins) || bins.length === 0) return null; + if (!isBinsArray(bins) || bins.length === 0) return null; const k = Math.floor(bins.length * 0.15); return nthElement(bins, k); } @@ -9190,12 +9221,12 @@ let _nthScratch = new Float64Array(0); let _smoothBins = []; function buildSpectrumRenderData(frame) { - if (!frame || !Array.isArray(frame.bins)) return frame; + if (!frame || !isBinsArray(frame.bins)) return frame; const n = frame.bins.length; const prev = lastSpectrumRenderData; const canBlend = prev && - Array.isArray(prev.bins) && + isBinsArray(prev.bins) && prev.bins.length === n && prev.sample_rate === frame.sample_rate && prev.center_hz === frame.center_hz; @@ -9238,7 +9269,7 @@ function canvasXToHz(cssX, cssW, range) { } function nearestSpectrumPeak(cssX, cssW, data) { - if (!data || !Array.isArray(data.bins) || data.bins.length === 0 || cssW <= 0) { + if (!data || !isBinsArray(data.bins) || data.bins.length === 0 || cssW <= 0) { return null; } @@ -9309,7 +9340,7 @@ function spectrumTargetHzAt(cssX, cssW, data) { } function visibleSpectrumPeakIndices(data, limit = 24) { - if (!data || !Array.isArray(data.bins) || data.bins.length < 3) { + if (!data || !isBinsArray(data.bins) || data.bins.length < 3) { return []; } @@ -9399,11 +9430,7 @@ function startSpectrumStreaming() { const sampleRate = Number(evt.data.slice(commaA + 1, commaB)); const b64 = evt.data.slice(commaB + 1); const hadSpectrum = !!lastSpectrumData; - const raw = atob(b64); - const len = raw.length; - if (_spectrumBinBuf.length !== len) _spectrumBinBuf = new Array(len); - const bins = _spectrumBinBuf; - for (let i = 0; i < len; i++) bins[i] = (raw.charCodeAt(i) << 24 >> 24); + const bins = decodeBase64ToInt8(b64); // Preserve any RDS data from the last rds event. const rds = lastSpectrumData?.rds; lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds }; @@ -9815,7 +9842,7 @@ function drawSpectrum(data) { } spectrumGl.drawFilledArea(spectrumTmpFillPoints, H, cssColorToRgba(pal.spectrumFill)); - if (Array.isArray(peakHoldBins) && peakHoldBins.length === n) { + if (isBinsArray(peakHoldBins) && peakHoldBins.length === n) { spectrumTmpPeakPoints.length = 0; for (let i = 0; i < n; i++) { spectrumTmpPeakPoints.push(binX(i), binYFromBins(peakHoldBins, i)); @@ -9932,7 +9959,7 @@ window.addEventListener("resize", _updateCachedCanvasSizes); _updateCachedCanvasSizes(); function pushSpectrumWaterfallFrame(data) { - if (!spectrumWaterfallCanvas || !data || !Array.isArray(data.bins) || data.bins.length === 0) return; + if (!spectrumWaterfallCanvas || !data || !isBinsArray(data.bins) || data.bins.length === 0) return; spectrumWfRows.push(data.bins.slice()); spectrumWfPushCount++; trimSpectrumWaterfallRows(); @@ -9997,7 +10024,7 @@ function drawSpectrumWaterfall() { ensureWaterfallLut(pal, minDb, maxDb); function renderRow(dstY, srcBins) { - if (!Array.isArray(srcBins) || srcBins.length === 0) return; + if (!isBinsArray(srcBins) || srcBins.length === 0) return; const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length); const spanBins = Math.max(1, endIdx - startIdx); const rowBase = dstY * rowStride; @@ -10767,7 +10794,7 @@ window.addEventListener("keydown", (event) => { // Auto: estimate from noise floor let auto = 30; const data = lastSpectrumData || window.lastSpectrumData; - if (data && Array.isArray(data.bins) && data.bins.length > 0) { + if (data && isBinsArray(data.bins) && data.bins.length > 0) { const noiseDb = estimateNoiseFloorDb(data.bins); if (noiseDb != null && Number.isFinite(noiseDb)) { const thresholdDb = noiseDb + 6; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index a195ac4..a738066 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::io::Write; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse, Responder}; use actix_web::{http::header, Error}; @@ -116,14 +116,16 @@ struct FrontendMeta { server_connected: bool, } -/// Wrapper that flattens a rig state with frontend meta into a single JSON -/// object, replacing the old string-level splice approach. +/// Direct-serialize wrapper: flattens snapshot + meta in a single serde pass, +/// avoiding the intermediate `serde_json::Value` round-trip used by +/// `inject_frontend_meta`. Used on the SSE hot path where state updates +/// arrive at high frequency. #[derive(serde::Serialize)] -struct StateWithMeta<'a> { +struct SnapshotWithMeta<'a> { #[serde(flatten)] - state: &'a serde_json::Value, + snapshot: &'a RigSnapshot, #[serde(flatten)] - meta: &'a FrontendMeta, + meta: FrontendMeta, } /// Tracks per-SSE-session rig selection so different browser tabs can @@ -183,38 +185,22 @@ pub async fn status_api( .filter(|s| !s.is_empty()) .and_then(|rid| context.rig_state_rx(rid)) .unwrap_or_else(|| state.get_ref().clone()); - let state = wait_for_view(rx).await?; - let json = serde_json::to_string(&state).map_err(actix_web::error::ErrorInternalServerError)?; - let json = inject_frontend_meta( - &json, - frontend_meta_from_context( + let snapshot = wait_for_view(rx).await?; + let combined = SnapshotWithMeta { + snapshot: &snapshot, + meta: frontend_meta_from_context( clients.load(Ordering::Relaxed), context.get_ref().as_ref(), None, ), - ); + }; + let json = + serde_json::to_string(&combined).map_err(actix_web::error::ErrorInternalServerError)?; Ok(HttpResponse::Ok() .insert_header((header::CONTENT_TYPE, "application/json")) .body(json)) } -/// Merge a rig state JSON string with frontend meta via `#[serde(flatten)]`. -/// -/// Parses the state once into a `serde_json::Value`, then serializes the -/// combined `StateWithMeta` wrapper in a single pass — cleaner and faster -/// than the old string-level splice approach. -fn inject_frontend_meta(json: &str, meta: FrontendMeta) -> String { - let state: serde_json::Value = match serde_json::from_str(json) { - Ok(v) => v, - Err(_) => return json.to_string(), - }; - let combined = StateWithMeta { - state: &state, - meta: &meta, - }; - serde_json::to_string(&combined).unwrap_or_else(|_| json.to_string()) -} - fn frontend_meta_from_context( http_clients: usize, context: &FrontendRuntimeContext, @@ -387,12 +373,16 @@ pub async fn events( } // Build the prefix burst: rig state → session UUID → initial channels. - let initial_json = - serde_json::to_string(&initial).map_err(actix_web::error::ErrorInternalServerError)?; - let initial_json = inject_frontend_meta( - &initial_json, - frontend_meta_from_context(count, context.get_ref().as_ref(), active_rig_id.as_deref()), - ); + let initial_combined = SnapshotWithMeta { + snapshot: &initial, + meta: frontend_meta_from_context( + count, + context.get_ref().as_ref(), + active_rig_id.as_deref(), + ), + }; + let initial_json = serde_json::to_string(&initial_combined) + .map_err(actix_web::error::ErrorInternalServerError)?; let mut prefix: Vec> = Vec::new(); prefix.push(Ok(Bytes::from(format!("data: {initial_json}\n\n")))); @@ -444,15 +434,15 @@ pub async fn events( rig_id, ); } - serde_json::to_string(&v).ok().map(|json| { - let json = inject_frontend_meta( - &json, - frontend_meta_from_context( - counter.load(Ordering::Relaxed), - context.as_ref(), - rig_id_opt.as_deref(), - ), - ); + let combined = SnapshotWithMeta { + snapshot: &v, + meta: frontend_meta_from_context( + counter.load(Ordering::Relaxed), + context.as_ref(), + rig_id_opt.as_deref(), + ), + }; + serde_json::to_string(&combined).ok().map(|json| { Ok::(Bytes::from(format!("data: {json}\n\n"))) }) }) @@ -1626,6 +1616,82 @@ where .body(body) } +/// Pre-compressed (gzip) + ETag-aware response for immutable embedded assets. +/// +/// Assets are embedded at compile time and never change within a build. +/// We pre-compress each asset once (via `OnceLock`) and serve the cached +/// gzip bytes with a strong ETag derived from the build version tag, so +/// browsers can cache aggressively and validate cheaply with `If-None-Match`. +fn static_asset_response( + req: &HttpRequest, + content_type: &'static str, + gz_bytes: &[u8], + etag: &str, +) -> HttpResponse { + // Check If-None-Match for conditional GET. + if let Some(inm) = req.headers().get(header::IF_NONE_MATCH) { + if let Ok(val) = inm.to_str() { + if val == etag || val == "*" { + return HttpResponse::NotModified() + .insert_header((header::ETAG, etag.to_owned())) + .insert_header((header::CACHE_CONTROL, "public, max-age=86400, must-revalidate")) + .finish(); + } + } + } + HttpResponse::Ok() + .insert_header((header::CONTENT_TYPE, content_type)) + .insert_header((header::CONTENT_ENCODING, "gzip")) + .insert_header((header::ETAG, etag.to_owned())) + .insert_header((header::CACHE_CONTROL, "public, max-age=86400, must-revalidate")) + .body(Bytes::copy_from_slice(gz_bytes)) +} + +/// Cache entry for a pre-compressed asset: gzip bytes + ETag string. +struct GzCacheEntry { + gz: Vec, + etag: String, +} + +/// Compress `src` with gzip and build an ETag from the build version + asset name. +fn gz_cache_entry(src: &[u8], name: &str) -> GzCacheEntry { + let mut encoder = GzEncoder::new(Vec::with_capacity(src.len() / 2), Compression::best()); + encoder.write_all(src).expect("gzip compress"); + let gz = encoder.finish().expect("gzip finish"); + let etag = format!("\"{}:{}\"", status::build_version_tag(), name); + GzCacheEntry { gz, etag } +} + +macro_rules! define_gz_cache { + ($fn_name:ident, $src:expr, $asset_name:literal) => { + fn $fn_name() -> &'static GzCacheEntry { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| gz_cache_entry($src.as_bytes(), $asset_name)) + } + }; +} + +define_gz_cache!(gz_index_html, status::index_html(), "index.html"); +define_gz_cache!(gz_style_css, status::STYLE_CSS, "style.css"); +define_gz_cache!(gz_app_js, status::APP_JS, "app.js"); +define_gz_cache!(gz_decode_history_worker_js, status::DECODE_HISTORY_WORKER_JS, "decode-history-worker.js"); +define_gz_cache!(gz_webgl_renderer_js, status::WEBGL_RENDERER_JS, "webgl-renderer.js"); +define_gz_cache!(gz_leaflet_ais_tracksymbol_js, status::LEAFLET_AIS_TRACKSYMBOL_JS, "leaflet-ais-tracksymbol.js"); +define_gz_cache!(gz_ais_js, status::AIS_JS, "ais.js"); +define_gz_cache!(gz_vdes_js, status::VDES_JS, "vdes.js"); +define_gz_cache!(gz_aprs_js, status::APRS_JS, "aprs.js"); +define_gz_cache!(gz_hf_aprs_js, status::HF_APRS_JS, "hf-aprs.js"); +define_gz_cache!(gz_ft8_js, status::FT8_JS, "ft8.js"); +define_gz_cache!(gz_ft4_js, status::FT4_JS, "ft4.js"); +define_gz_cache!(gz_ft2_js, status::FT2_JS, "ft2.js"); +define_gz_cache!(gz_wspr_js, status::WSPR_JS, "wspr.js"); +define_gz_cache!(gz_cw_js, status::CW_JS, "cw.js"); +define_gz_cache!(gz_sat_js, status::SAT_JS, "sat.js"); +define_gz_cache!(gz_bookmarks_js, status::BOOKMARKS_JS, "bookmarks.js"); +define_gz_cache!(gz_scheduler_js, status::SCHEDULER_JS, "scheduler.js"); +define_gz_cache!(gz_background_decode_js, status::BACKGROUND_DECODE_JS, "background-decode.js"); +define_gz_cache!(gz_vchan_js, status::VCHAN_JS, "vchan.js"); + /// A bookmark with its owning scope tag for the list response. #[derive(serde::Serialize)] struct BookmarkWithScope { @@ -2200,34 +2266,40 @@ pub fn configure(cfg: &mut web::ServiceConfig) { } #[get("/")] -async fn index() -> impl Responder { - no_cache_response("text/html; charset=utf-8", status::index_html()) +async fn index(req: HttpRequest) -> impl Responder { + let c = gz_index_html(); + static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag) } #[get("/map")] -async fn map_index() -> impl Responder { - no_cache_response("text/html; charset=utf-8", status::index_html()) +async fn map_index(req: HttpRequest) -> impl Responder { + let c = gz_index_html(); + static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag) } #[get("/digital-modes")] -async fn digital_modes_index() -> impl Responder { - no_cache_response("text/html; charset=utf-8", status::index_html()) +async fn digital_modes_index(req: HttpRequest) -> impl Responder { + let c = gz_index_html(); + static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag) } #[get("/settings")] -async fn settings_index() -> impl Responder { - no_cache_response("text/html; charset=utf-8", status::index_html()) +async fn settings_index(req: HttpRequest) -> impl Responder { + let c = gz_index_html(); + static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag) } #[get("/about")] -async fn about_index() -> impl Responder { - no_cache_response("text/html; charset=utf-8", status::index_html()) +async fn about_index(req: HttpRequest) -> impl Responder { + let c = gz_index_html(); + static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag) } #[get("/favicon.ico")] async fn favicon() -> impl Responder { HttpResponse::Ok() .insert_header((header::CONTENT_TYPE, "image/png")) + .insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable")) .body(FAVICON_BYTES) } @@ -2235,6 +2307,7 @@ async fn favicon() -> impl Responder { async fn favicon_png() -> impl Responder { HttpResponse::Ok() .insert_header((header::CONTENT_TYPE, "image/png")) + .insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable")) .body(FAVICON_BYTES) } @@ -2242,120 +2315,122 @@ async fn favicon_png() -> impl Responder { async fn logo() -> impl Responder { HttpResponse::Ok() .insert_header((header::CONTENT_TYPE, "image/png")) + .insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable")) .body(LOGO_BYTES) } #[get("/style.css")] -async fn style_css() -> impl Responder { - no_cache_response("text/css; charset=utf-8", status::STYLE_CSS) +async fn style_css(req: HttpRequest) -> impl Responder { + let c = gz_style_css(); + static_asset_response(&req, "text/css; charset=utf-8", &c.gz, &c.etag) } #[get("/app.js")] -async fn app_js() -> impl Responder { - no_cache_response("application/javascript; charset=utf-8", status::APP_JS) +async fn app_js(req: HttpRequest) -> impl Responder { + let c = gz_app_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/decode-history-worker.js")] -async fn decode_history_worker_js() -> impl Responder { - no_cache_response( - "application/javascript; charset=utf-8", - status::DECODE_HISTORY_WORKER_JS, - ) +async fn decode_history_worker_js(req: HttpRequest) -> impl Responder { + let c = gz_decode_history_worker_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/webgl-renderer.js")] -async fn webgl_renderer_js() -> impl Responder { - no_cache_response( - "application/javascript; charset=utf-8", - status::WEBGL_RENDERER_JS, - ) +async fn webgl_renderer_js(req: HttpRequest) -> impl Responder { + let c = gz_webgl_renderer_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/leaflet-ais-tracksymbol.js")] -async fn leaflet_ais_tracksymbol_js() -> impl Responder { - no_cache_response( - "application/javascript; charset=utf-8", - status::LEAFLET_AIS_TRACKSYMBOL_JS, - ) +async fn leaflet_ais_tracksymbol_js(req: HttpRequest) -> impl Responder { + let c = gz_leaflet_ais_tracksymbol_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/aprs.js")] -async fn aprs_js() -> impl Responder { - no_cache_response("application/javascript; charset=utf-8", status::APRS_JS) +async fn aprs_js(req: HttpRequest) -> impl Responder { + let c = gz_aprs_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/hf-aprs.js")] -async fn hf_aprs_js() -> impl Responder { - no_cache_response("application/javascript; charset=utf-8", status::HF_APRS_JS) +async fn hf_aprs_js(req: HttpRequest) -> impl Responder { + let c = gz_hf_aprs_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/ais.js")] -async fn ais_js() -> impl Responder { - no_cache_response("application/javascript; charset=utf-8", status::AIS_JS) +async fn ais_js(req: HttpRequest) -> impl Responder { + let c = gz_ais_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/vdes.js")] -async fn vdes_js() -> impl Responder { - no_cache_response("application/javascript; charset=utf-8", status::VDES_JS) +async fn vdes_js(req: HttpRequest) -> impl Responder { + let c = gz_vdes_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/ft8.js")] -async fn ft8_js() -> impl Responder { - no_cache_response("application/javascript; charset=utf-8", status::FT8_JS) +async fn ft8_js(req: HttpRequest) -> impl Responder { + let c = gz_ft8_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/ft4.js")] -async fn ft4_js() -> impl Responder { - no_cache_response("application/javascript; charset=utf-8", status::FT4_JS) +async fn ft4_js(req: HttpRequest) -> impl Responder { + let c = gz_ft4_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/ft2.js")] -async fn ft2_js() -> impl Responder { - no_cache_response("application/javascript; charset=utf-8", status::FT2_JS) +async fn ft2_js(req: HttpRequest) -> impl Responder { + let c = gz_ft2_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/wspr.js")] -async fn wspr_js() -> impl Responder { - no_cache_response("application/javascript; charset=utf-8", status::WSPR_JS) +async fn wspr_js(req: HttpRequest) -> impl Responder { + let c = gz_wspr_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/cw.js")] -async fn cw_js() -> impl Responder { - no_cache_response("application/javascript; charset=utf-8", status::CW_JS) +async fn cw_js(req: HttpRequest) -> impl Responder { + let c = gz_cw_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/sat.js")] -async fn sat_js() -> impl Responder { - no_cache_response("application/javascript; charset=utf-8", status::SAT_JS) +async fn sat_js(req: HttpRequest) -> impl Responder { + let c = gz_sat_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/bookmarks.js")] -async fn bookmarks_js() -> impl Responder { - no_cache_response( - "application/javascript; charset=utf-8", - status::BOOKMARKS_JS, - ) +async fn bookmarks_js(req: HttpRequest) -> impl Responder { + let c = gz_bookmarks_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/scheduler.js")] -async fn scheduler_js() -> impl Responder { - no_cache_response( - "application/javascript; charset=utf-8", - status::SCHEDULER_JS, - ) +async fn scheduler_js(req: HttpRequest) -> impl Responder { + let c = gz_scheduler_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/background-decode.js")] -async fn background_decode_js() -> impl Responder { - no_cache_response( - "application/javascript; charset=utf-8", - status::BACKGROUND_DECODE_JS, - ) +async fn background_decode_js(req: HttpRequest) -> impl Responder { + let c = gz_background_decode_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } #[get("/vchan.js")] -async fn vchan_js() -> impl Responder { - no_cache_response("application/javascript; charset=utf-8", status::VCHAN_JS) +async fn vchan_js(req: HttpRequest) -> impl Responder { + let c = gz_vchan_js(); + static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag) } /// Generic query extractor for endpoints that only need the optional remote. diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs index da2f913..6bd906c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: BSD-2-Clause +use std::sync::OnceLock; + const PKG_NAME: &str = env!("CARGO_PKG_NAME"); const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); const CLIENT_BUILD_DATE: &str = env!("TRX_CLIENT_BUILD_DATE"); @@ -28,9 +30,22 @@ pub const SCHEDULER_JS: &str = include_str!("../assets/web/plugins/scheduler.js" pub const BACKGROUND_DECODE_JS: &str = include_str!("../assets/web/plugins/background-decode.js"); pub const VCHAN_JS: &str = include_str!("../assets/web/plugins/vchan.js"); -pub fn index_html() -> String { - INDEX_HTML - .replace("{pkg}", PKG_NAME) - .replace("{ver}", PKG_VERSION) - .replace("{client_build_date}", CLIENT_BUILD_DATE) +/// Build version tag used for cache-busting asset URLs and ETag headers. +/// Computed once from `PKG_VERSION` + `CLIENT_BUILD_DATE`. +pub fn build_version_tag() -> &'static str { + static TAG: OnceLock = OnceLock::new(); + TAG.get_or_init(|| format!("{PKG_VERSION}-{CLIENT_BUILD_DATE}")) +} + +/// Pre-computed index HTML with version/date placeholders resolved. +/// Computed once on first access, avoiding three `.replace()` calls per +/// request on the ~50 KB HTML template. +pub fn index_html() -> &'static str { + static HTML: OnceLock = OnceLock::new(); + HTML.get_or_init(|| { + INDEX_HTML + .replace("{pkg}", PKG_NAME) + .replace("{ver}", PKG_VERSION) + .replace("{client_build_date}", CLIENT_BUILD_DATE) + }) }