[feat](trx-frontend-http): optimize HTTP frontend performance
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1440,7 +1440,7 @@ function overviewVisibleBinWindow(data, binCount) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pushOverviewWaterfallFrame(data) {
|
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());
|
overviewWaterfallRows.push(data.bins.slice());
|
||||||
overviewWaterfallPushCount++;
|
overviewWaterfallPushCount++;
|
||||||
trimOverviewWaterfallRows();
|
trimOverviewWaterfallRows();
|
||||||
@@ -1502,7 +1502,7 @@ function drawOverviewWaterfall(W, H, pal) {
|
|||||||
ensureWaterfallLut(pal, minDb, maxDb);
|
ensureWaterfallLut(pal, minDb, maxDb);
|
||||||
|
|
||||||
function renderRow(dstY, srcBins) {
|
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 { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length);
|
||||||
const spanBins = Math.max(1, endIdx - startIdx);
|
const spanBins = Math.max(1, endIdx - startIdx);
|
||||||
const rowBase = dstY * rowStride;
|
const rowBase = dstY * rowStride;
|
||||||
@@ -2200,7 +2200,7 @@ function setRigFrequency(freqHz) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function spectrumBinIndexForHz(data, hz) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
const maxIdx = data.bins.length - 1;
|
const maxIdx = data.bins.length - 1;
|
||||||
@@ -2216,7 +2216,7 @@ function spectrumPowerScore(db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(freqHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
if (!Number.isFinite(freqHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||||
@@ -4167,7 +4167,7 @@ async function applyBandwidthFromInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function estimateBandwidthAroundPeak(data, centerHz) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7976,7 +7976,7 @@ if (sdrSquelchAutoBtn) {
|
|||||||
if (!sdrSquelchSupported) return;
|
if (!sdrSquelchSupported) return;
|
||||||
let pct = 0; // default: Off
|
let pct = 0; // default: Off
|
||||||
const data = lastSpectrumData || window.lastSpectrumData;
|
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);
|
const noiseDb = estimateNoiseFloorDb(data.bins);
|
||||||
if (noiseDb != null && Number.isFinite(noiseDb)) {
|
if (noiseDb != null && Number.isFinite(noiseDb)) {
|
||||||
// Set threshold slightly above noise floor so squelch closes on noise
|
// 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_HEADROOM_DB = 20;
|
||||||
const SPECTRUM_SMOOTH_ALPHA = 0.42;
|
const SPECTRUM_SMOOTH_ALPHA = 0.42;
|
||||||
let _spectrumBinBuf = []; // Reusable buffer for SSE bin decoding
|
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).
|
// Crosshair state (CSS coords relative to spectrum canvas).
|
||||||
let spectrumCrosshairX = null;
|
let spectrumCrosshairX = null;
|
||||||
@@ -9115,14 +9146,14 @@ function pruneSpectrumPeakHoldFrames(now = Date.now()) {
|
|||||||
let removeCount = 0;
|
let removeCount = 0;
|
||||||
for (let i = 0; i < spectrumPeakHoldFrames.length; i++) {
|
for (let i = 0; i < spectrumPeakHoldFrames.length; i++) {
|
||||||
const f = spectrumPeakHoldFrames[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++;
|
removeCount++;
|
||||||
}
|
}
|
||||||
if (removeCount > 0) spectrumPeakHoldFrames.splice(0, removeCount);
|
if (removeCount > 0) spectrumPeakHoldFrames.splice(0, removeCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushSpectrumPeakHoldFrame(frame) {
|
function pushSpectrumPeakHoldFrame(frame) {
|
||||||
if (!frame || !Array.isArray(frame.bins) || frame.bins.length === 0) {
|
if (!frame || !isBinsArray(frame.bins) || frame.bins.length === 0) {
|
||||||
clearSpectrumPeakHoldFrames();
|
clearSpectrumPeakHoldFrames();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -9142,14 +9173,14 @@ function pushSpectrumPeakHoldFrame(frame) {
|
|||||||
|
|
||||||
function buildSpectrumPeakHoldBins(currentBins) {
|
function buildSpectrumPeakHoldBins(currentBins) {
|
||||||
const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
pruneSpectrumPeakHoldFrames();
|
pruneSpectrumPeakHoldFrames();
|
||||||
if (spectrumPeakHoldFrames.length === 0) return null;
|
if (spectrumPeakHoldFrames.length === 0) return null;
|
||||||
const peakBins = currentBins.slice();
|
const peakBins = currentBins.slice();
|
||||||
for (const frame of spectrumPeakHoldFrames) {
|
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++) {
|
for (let i = 0; i < peakBins.length; i++) {
|
||||||
if (frame.bins[i] > peakBins[i]) peakBins[i] = frame.bins[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).
|
// 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.
|
// Uses O(N) nth-element selection instead of O(N log N) sort.
|
||||||
function estimateNoiseFloorDb(bins) {
|
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);
|
const k = Math.floor(bins.length * 0.15);
|
||||||
return nthElement(bins, k);
|
return nthElement(bins, k);
|
||||||
}
|
}
|
||||||
@@ -9190,12 +9221,12 @@ let _nthScratch = new Float64Array(0);
|
|||||||
let _smoothBins = [];
|
let _smoothBins = [];
|
||||||
|
|
||||||
function buildSpectrumRenderData(frame) {
|
function buildSpectrumRenderData(frame) {
|
||||||
if (!frame || !Array.isArray(frame.bins)) return frame;
|
if (!frame || !isBinsArray(frame.bins)) return frame;
|
||||||
const n = frame.bins.length;
|
const n = frame.bins.length;
|
||||||
const prev = lastSpectrumRenderData;
|
const prev = lastSpectrumRenderData;
|
||||||
const canBlend =
|
const canBlend =
|
||||||
prev &&
|
prev &&
|
||||||
Array.isArray(prev.bins) &&
|
isBinsArray(prev.bins) &&
|
||||||
prev.bins.length === n &&
|
prev.bins.length === n &&
|
||||||
prev.sample_rate === frame.sample_rate &&
|
prev.sample_rate === frame.sample_rate &&
|
||||||
prev.center_hz === frame.center_hz;
|
prev.center_hz === frame.center_hz;
|
||||||
@@ -9238,7 +9269,7 @@ function canvasXToHz(cssX, cssW, range) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nearestSpectrumPeak(cssX, cssW, data) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9309,7 +9340,7 @@ function spectrumTargetHzAt(cssX, cssW, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function visibleSpectrumPeakIndices(data, limit = 24) {
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9399,11 +9430,7 @@ function startSpectrumStreaming() {
|
|||||||
const sampleRate = Number(evt.data.slice(commaA + 1, commaB));
|
const sampleRate = Number(evt.data.slice(commaA + 1, commaB));
|
||||||
const b64 = evt.data.slice(commaB + 1);
|
const b64 = evt.data.slice(commaB + 1);
|
||||||
const hadSpectrum = !!lastSpectrumData;
|
const hadSpectrum = !!lastSpectrumData;
|
||||||
const raw = atob(b64);
|
const bins = decodeBase64ToInt8(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);
|
|
||||||
// Preserve any RDS data from the last rds event.
|
// Preserve any RDS data from the last rds event.
|
||||||
const rds = lastSpectrumData?.rds;
|
const rds = lastSpectrumData?.rds;
|
||||||
lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds };
|
lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds };
|
||||||
@@ -9815,7 +9842,7 @@ function drawSpectrum(data) {
|
|||||||
}
|
}
|
||||||
spectrumGl.drawFilledArea(spectrumTmpFillPoints, H, cssColorToRgba(pal.spectrumFill));
|
spectrumGl.drawFilledArea(spectrumTmpFillPoints, H, cssColorToRgba(pal.spectrumFill));
|
||||||
|
|
||||||
if (Array.isArray(peakHoldBins) && peakHoldBins.length === n) {
|
if (isBinsArray(peakHoldBins) && peakHoldBins.length === n) {
|
||||||
spectrumTmpPeakPoints.length = 0;
|
spectrumTmpPeakPoints.length = 0;
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
spectrumTmpPeakPoints.push(binX(i), binYFromBins(peakHoldBins, i));
|
spectrumTmpPeakPoints.push(binX(i), binYFromBins(peakHoldBins, i));
|
||||||
@@ -9932,7 +9959,7 @@ window.addEventListener("resize", _updateCachedCanvasSizes);
|
|||||||
_updateCachedCanvasSizes();
|
_updateCachedCanvasSizes();
|
||||||
|
|
||||||
function pushSpectrumWaterfallFrame(data) {
|
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());
|
spectrumWfRows.push(data.bins.slice());
|
||||||
spectrumWfPushCount++;
|
spectrumWfPushCount++;
|
||||||
trimSpectrumWaterfallRows();
|
trimSpectrumWaterfallRows();
|
||||||
@@ -9997,7 +10024,7 @@ function drawSpectrumWaterfall() {
|
|||||||
ensureWaterfallLut(pal, minDb, maxDb);
|
ensureWaterfallLut(pal, minDb, maxDb);
|
||||||
|
|
||||||
function renderRow(dstY, srcBins) {
|
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 { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length);
|
||||||
const spanBins = Math.max(1, endIdx - startIdx);
|
const spanBins = Math.max(1, endIdx - startIdx);
|
||||||
const rowBase = dstY * rowStride;
|
const rowBase = dstY * rowStride;
|
||||||
@@ -10767,7 +10794,7 @@ window.addEventListener("keydown", (event) => {
|
|||||||
// Auto: estimate from noise floor
|
// Auto: estimate from noise floor
|
||||||
let auto = 30;
|
let auto = 30;
|
||||||
const data = lastSpectrumData || window.lastSpectrumData;
|
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);
|
const noiseDb = estimateNoiseFloorDb(data.bins);
|
||||||
if (noiseDb != null && Number.isFinite(noiseDb)) {
|
if (noiseDb != null && Number.isFinite(noiseDb)) {
|
||||||
const thresholdDb = noiseDb + 6;
|
const thresholdDb = noiseDb + 6;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
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::{delete, get, post, put, web, HttpRequest, HttpResponse, Responder};
|
||||||
use actix_web::{http::header, Error};
|
use actix_web::{http::header, Error};
|
||||||
@@ -116,14 +116,16 @@ struct FrontendMeta {
|
|||||||
server_connected: bool,
|
server_connected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapper that flattens a rig state with frontend meta into a single JSON
|
/// Direct-serialize wrapper: flattens snapshot + meta in a single serde pass,
|
||||||
/// object, replacing the old string-level splice approach.
|
/// 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)]
|
#[derive(serde::Serialize)]
|
||||||
struct StateWithMeta<'a> {
|
struct SnapshotWithMeta<'a> {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
state: &'a serde_json::Value,
|
snapshot: &'a RigSnapshot,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
meta: &'a FrontendMeta,
|
meta: FrontendMeta,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tracks per-SSE-session rig selection so different browser tabs can
|
/// 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())
|
.filter(|s| !s.is_empty())
|
||||||
.and_then(|rid| context.rig_state_rx(rid))
|
.and_then(|rid| context.rig_state_rx(rid))
|
||||||
.unwrap_or_else(|| state.get_ref().clone());
|
.unwrap_or_else(|| state.get_ref().clone());
|
||||||
let state = wait_for_view(rx).await?;
|
let snapshot = wait_for_view(rx).await?;
|
||||||
let json = serde_json::to_string(&state).map_err(actix_web::error::ErrorInternalServerError)?;
|
let combined = SnapshotWithMeta {
|
||||||
let json = inject_frontend_meta(
|
snapshot: &snapshot,
|
||||||
&json,
|
meta: frontend_meta_from_context(
|
||||||
frontend_meta_from_context(
|
|
||||||
clients.load(Ordering::Relaxed),
|
clients.load(Ordering::Relaxed),
|
||||||
context.get_ref().as_ref(),
|
context.get_ref().as_ref(),
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
);
|
};
|
||||||
|
let json =
|
||||||
|
serde_json::to_string(&combined).map_err(actix_web::error::ErrorInternalServerError)?;
|
||||||
Ok(HttpResponse::Ok()
|
Ok(HttpResponse::Ok()
|
||||||
.insert_header((header::CONTENT_TYPE, "application/json"))
|
.insert_header((header::CONTENT_TYPE, "application/json"))
|
||||||
.body(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(
|
fn frontend_meta_from_context(
|
||||||
http_clients: usize,
|
http_clients: usize,
|
||||||
context: &FrontendRuntimeContext,
|
context: &FrontendRuntimeContext,
|
||||||
@@ -387,12 +373,16 @@ pub async fn events(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build the prefix burst: rig state → session UUID → initial channels.
|
// Build the prefix burst: rig state → session UUID → initial channels.
|
||||||
let initial_json =
|
let initial_combined = SnapshotWithMeta {
|
||||||
serde_json::to_string(&initial).map_err(actix_web::error::ErrorInternalServerError)?;
|
snapshot: &initial,
|
||||||
let initial_json = inject_frontend_meta(
|
meta: frontend_meta_from_context(
|
||||||
&initial_json,
|
count,
|
||||||
frontend_meta_from_context(count, context.get_ref().as_ref(), active_rig_id.as_deref()),
|
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<Result<Bytes, Error>> = Vec::new();
|
let mut prefix: Vec<Result<Bytes, Error>> = Vec::new();
|
||||||
prefix.push(Ok(Bytes::from(format!("data: {initial_json}\n\n"))));
|
prefix.push(Ok(Bytes::from(format!("data: {initial_json}\n\n"))));
|
||||||
@@ -444,15 +434,15 @@ pub async fn events(
|
|||||||
rig_id,
|
rig_id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
serde_json::to_string(&v).ok().map(|json| {
|
let combined = SnapshotWithMeta {
|
||||||
let json = inject_frontend_meta(
|
snapshot: &v,
|
||||||
&json,
|
meta: frontend_meta_from_context(
|
||||||
frontend_meta_from_context(
|
counter.load(Ordering::Relaxed),
|
||||||
counter.load(Ordering::Relaxed),
|
context.as_ref(),
|
||||||
context.as_ref(),
|
rig_id_opt.as_deref(),
|
||||||
rig_id_opt.as_deref(),
|
),
|
||||||
),
|
};
|
||||||
);
|
serde_json::to_string(&combined).ok().map(|json| {
|
||||||
Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n")))
|
Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n")))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1626,6 +1616,82 @@ where
|
|||||||
.body(body)
|
.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<u8>,
|
||||||
|
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<GzCacheEntry> = 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.
|
/// A bookmark with its owning scope tag for the list response.
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct BookmarkWithScope {
|
struct BookmarkWithScope {
|
||||||
@@ -2200,34 +2266,40 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
async fn index() -> impl Responder {
|
async fn index(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("text/html; charset=utf-8", status::index_html())
|
let c = gz_index_html();
|
||||||
|
static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/map")]
|
#[get("/map")]
|
||||||
async fn map_index() -> impl Responder {
|
async fn map_index(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("text/html; charset=utf-8", status::index_html())
|
let c = gz_index_html();
|
||||||
|
static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/digital-modes")]
|
#[get("/digital-modes")]
|
||||||
async fn digital_modes_index() -> impl Responder {
|
async fn digital_modes_index(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("text/html; charset=utf-8", status::index_html())
|
let c = gz_index_html();
|
||||||
|
static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/settings")]
|
#[get("/settings")]
|
||||||
async fn settings_index() -> impl Responder {
|
async fn settings_index(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("text/html; charset=utf-8", status::index_html())
|
let c = gz_index_html();
|
||||||
|
static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/about")]
|
#[get("/about")]
|
||||||
async fn about_index() -> impl Responder {
|
async fn about_index(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("text/html; charset=utf-8", status::index_html())
|
let c = gz_index_html();
|
||||||
|
static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/favicon.ico")]
|
#[get("/favicon.ico")]
|
||||||
async fn favicon() -> impl Responder {
|
async fn favicon() -> impl Responder {
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||||
|
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||||
.body(FAVICON_BYTES)
|
.body(FAVICON_BYTES)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2235,6 +2307,7 @@ async fn favicon() -> impl Responder {
|
|||||||
async fn favicon_png() -> impl Responder {
|
async fn favicon_png() -> impl Responder {
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||||
|
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||||
.body(FAVICON_BYTES)
|
.body(FAVICON_BYTES)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2242,120 +2315,122 @@ async fn favicon_png() -> impl Responder {
|
|||||||
async fn logo() -> impl Responder {
|
async fn logo() -> impl Responder {
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||||
|
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||||
.body(LOGO_BYTES)
|
.body(LOGO_BYTES)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/style.css")]
|
#[get("/style.css")]
|
||||||
async fn style_css() -> impl Responder {
|
async fn style_css(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("text/css; charset=utf-8", status::STYLE_CSS)
|
let c = gz_style_css();
|
||||||
|
static_asset_response(&req, "text/css; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/app.js")]
|
#[get("/app.js")]
|
||||||
async fn app_js() -> impl Responder {
|
async fn app_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("application/javascript; charset=utf-8", status::APP_JS)
|
let c = gz_app_js();
|
||||||
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/decode-history-worker.js")]
|
#[get("/decode-history-worker.js")]
|
||||||
async fn decode_history_worker_js() -> impl Responder {
|
async fn decode_history_worker_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response(
|
let c = gz_decode_history_worker_js();
|
||||||
"application/javascript; charset=utf-8",
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
status::DECODE_HISTORY_WORKER_JS,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/webgl-renderer.js")]
|
#[get("/webgl-renderer.js")]
|
||||||
async fn webgl_renderer_js() -> impl Responder {
|
async fn webgl_renderer_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response(
|
let c = gz_webgl_renderer_js();
|
||||||
"application/javascript; charset=utf-8",
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
status::WEBGL_RENDERER_JS,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/leaflet-ais-tracksymbol.js")]
|
#[get("/leaflet-ais-tracksymbol.js")]
|
||||||
async fn leaflet_ais_tracksymbol_js() -> impl Responder {
|
async fn leaflet_ais_tracksymbol_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response(
|
let c = gz_leaflet_ais_tracksymbol_js();
|
||||||
"application/javascript; charset=utf-8",
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
status::LEAFLET_AIS_TRACKSYMBOL_JS,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/aprs.js")]
|
#[get("/aprs.js")]
|
||||||
async fn aprs_js() -> impl Responder {
|
async fn aprs_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("application/javascript; charset=utf-8", status::APRS_JS)
|
let c = gz_aprs_js();
|
||||||
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/hf-aprs.js")]
|
#[get("/hf-aprs.js")]
|
||||||
async fn hf_aprs_js() -> impl Responder {
|
async fn hf_aprs_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("application/javascript; charset=utf-8", status::HF_APRS_JS)
|
let c = gz_hf_aprs_js();
|
||||||
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/ais.js")]
|
#[get("/ais.js")]
|
||||||
async fn ais_js() -> impl Responder {
|
async fn ais_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("application/javascript; charset=utf-8", status::AIS_JS)
|
let c = gz_ais_js();
|
||||||
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/vdes.js")]
|
#[get("/vdes.js")]
|
||||||
async fn vdes_js() -> impl Responder {
|
async fn vdes_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("application/javascript; charset=utf-8", status::VDES_JS)
|
let c = gz_vdes_js();
|
||||||
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/ft8.js")]
|
#[get("/ft8.js")]
|
||||||
async fn ft8_js() -> impl Responder {
|
async fn ft8_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("application/javascript; charset=utf-8", status::FT8_JS)
|
let c = gz_ft8_js();
|
||||||
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/ft4.js")]
|
#[get("/ft4.js")]
|
||||||
async fn ft4_js() -> impl Responder {
|
async fn ft4_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("application/javascript; charset=utf-8", status::FT4_JS)
|
let c = gz_ft4_js();
|
||||||
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/ft2.js")]
|
#[get("/ft2.js")]
|
||||||
async fn ft2_js() -> impl Responder {
|
async fn ft2_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("application/javascript; charset=utf-8", status::FT2_JS)
|
let c = gz_ft2_js();
|
||||||
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/wspr.js")]
|
#[get("/wspr.js")]
|
||||||
async fn wspr_js() -> impl Responder {
|
async fn wspr_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("application/javascript; charset=utf-8", status::WSPR_JS)
|
let c = gz_wspr_js();
|
||||||
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/cw.js")]
|
#[get("/cw.js")]
|
||||||
async fn cw_js() -> impl Responder {
|
async fn cw_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("application/javascript; charset=utf-8", status::CW_JS)
|
let c = gz_cw_js();
|
||||||
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/sat.js")]
|
#[get("/sat.js")]
|
||||||
async fn sat_js() -> impl Responder {
|
async fn sat_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("application/javascript; charset=utf-8", status::SAT_JS)
|
let c = gz_sat_js();
|
||||||
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/bookmarks.js")]
|
#[get("/bookmarks.js")]
|
||||||
async fn bookmarks_js() -> impl Responder {
|
async fn bookmarks_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response(
|
let c = gz_bookmarks_js();
|
||||||
"application/javascript; charset=utf-8",
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
status::BOOKMARKS_JS,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/scheduler.js")]
|
#[get("/scheduler.js")]
|
||||||
async fn scheduler_js() -> impl Responder {
|
async fn scheduler_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response(
|
let c = gz_scheduler_js();
|
||||||
"application/javascript; charset=utf-8",
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
status::SCHEDULER_JS,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/background-decode.js")]
|
#[get("/background-decode.js")]
|
||||||
async fn background_decode_js() -> impl Responder {
|
async fn background_decode_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response(
|
let c = gz_background_decode_js();
|
||||||
"application/javascript; charset=utf-8",
|
static_asset_response(&req, "application/javascript; charset=utf-8", &c.gz, &c.etag)
|
||||||
status::BACKGROUND_DECODE_JS,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/vchan.js")]
|
#[get("/vchan.js")]
|
||||||
async fn vchan_js() -> impl Responder {
|
async fn vchan_js(req: HttpRequest) -> impl Responder {
|
||||||
no_cache_response("application/javascript; charset=utf-8", status::VCHAN_JS)
|
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.
|
/// Generic query extractor for endpoints that only need the optional remote.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
const PKG_NAME: &str = env!("CARGO_PKG_NAME");
|
const PKG_NAME: &str = env!("CARGO_PKG_NAME");
|
||||||
const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
|
const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
const CLIENT_BUILD_DATE: &str = env!("TRX_CLIENT_BUILD_DATE");
|
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 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 const VCHAN_JS: &str = include_str!("../assets/web/plugins/vchan.js");
|
||||||
|
|
||||||
pub fn index_html() -> String {
|
/// Build version tag used for cache-busting asset URLs and ETag headers.
|
||||||
INDEX_HTML
|
/// Computed once from `PKG_VERSION` + `CLIENT_BUILD_DATE`.
|
||||||
.replace("{pkg}", PKG_NAME)
|
pub fn build_version_tag() -> &'static str {
|
||||||
.replace("{ver}", PKG_VERSION)
|
static TAG: OnceLock<String> = OnceLock::new();
|
||||||
.replace("{client_build_date}", CLIENT_BUILD_DATE)
|
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<String> = OnceLock::new();
|
||||||
|
HTML.get_or_init(|| {
|
||||||
|
INDEX_HTML
|
||||||
|
.replace("{pkg}", PKG_NAME)
|
||||||
|
.replace("{ver}", PKG_VERSION)
|
||||||
|
.replace("{client_build_date}", CLIENT_BUILD_DATE)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user