[fix](trx-frontend-http): async decode history replay to unblock UI

Server emits an SSE sentinel event (history_done) after replaying
stored history. Client buffers all incoming messages until the sentinel
arrives, then drains the buffer in 30-event chunks via setTimeout so
the browser can handle input between batches. Live events after the
sentinel are dispatched immediately as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-07 09:12:13 +01:00
parent b23f05966f
commit e2c568a98a
2 changed files with 31 additions and 7 deletions
@@ -5982,6 +5982,22 @@ function updateDecodeStatus(text) {
setModeBoundDecodeStatus(cw, ["CW", "CWR"], "Select CW mode to decode", cwText);
if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text;
}
function dispatchDecodeMessage(msg) {
if (msg.type === "ais" && window.onServerAis) window.onServerAis(msg);
if (msg.type === "vdes" && window.onServerVdes) window.onServerVdes(msg);
if (msg.type === "aprs" && window.onServerAprs) window.onServerAprs(msg);
if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg);
if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg);
}
function drainDecodeHistory(buffer, index) {
const CHUNK = 30;
const end = Math.min(index + CHUNK, buffer.length);
for (let i = index; i < end; i++) dispatchDecodeMessage(buffer[i]);
if (end < buffer.length) setTimeout(() => drainDecodeHistory(buffer, end), 0);
}
function connectDecode() {
if (decodeSource) { decodeSource.close(); }
if (window.resetAisHistoryView) window.resetAisHistoryView();
@@ -5990,20 +6006,25 @@ function connectDecode() {
if (window.resetCwHistoryView) window.resetCwHistoryView();
if (window.resetFt8HistoryView) window.resetFt8HistoryView();
if (window.resetWsprHistoryView) window.resetWsprHistoryView();
const historyBuffer = [];
let historyDone = false;
decodeSource = new EventSource("/decode");
decodeSource.onopen = () => {
decodeConnected = true;
updateDecodeStatus("Connected, listening for packets");
};
decodeSource.addEventListener("history_done", () => {
historyDone = true;
drainDecodeHistory(historyBuffer, 0);
});
decodeSource.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (msg.type === "ais" && window.onServerAis) window.onServerAis(msg);
if (msg.type === "vdes" && window.onServerVdes) window.onServerVdes(msg);
if (msg.type === "aprs" && window.onServerAprs) window.onServerAprs(msg);
if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg);
if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg);
if (!historyDone) {
historyBuffer.push(msg);
} else {
dispatchDecodeMessage(msg);
}
} catch (e) {
// ignore parse errors
}
@@ -307,6 +307,9 @@ pub async fn decode_events(
.map(|json| Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n"))))
}));
let history_done =
once(async { Ok::<Bytes, Error>(Bytes::from("event: history_done\ndata: {}\n\n")) });
let decode_stream = futures_util::stream::unfold(decode_rx, |mut rx| async move {
loop {
match rx.recv().await {
@@ -327,7 +330,7 @@ pub async fn decode_events(
let pings = IntervalStream::new(time::interval(Duration::from_secs(15)))
.map(|_| Ok::<Bytes, Error>(Bytes::from(": ping\n\n")));
let stream = history_stream.chain(select(pings, decode_stream));
let stream = history_stream.chain(history_done).chain(select(pings, decode_stream));
Ok(HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "text/event-stream"))