diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js
index b4659723..ea21ccf1 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js
@@ -228,15 +228,15 @@
}
aisWindow.updateAisBar = updateAisBar;
aisWindow.clearAisBar = function() {
- aisWindow.resetAisHistoryView?.();
+ resetAisHistoryView();
};
- aisWindow.resetAisHistoryView = function() {
+ function resetAisHistoryView() {
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
aisMessageHistory = [];
updateAisBar();
renderAisHistory();
aisWindow.clearMapMarkersByType?.("ais");
- };
+ }
function renderAisHistory() {
pruneAisMessageHistory();
if (!aisMessagesEl) {
@@ -272,7 +272,7 @@
rig_id: msg.rig_id || null
};
}
- aisWindow.onServerAisBatch = function(messages) {
+ function onServerAisBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized = [];
@@ -295,21 +295,18 @@
pruneAisMessageHistory();
scheduleAisBarUpdate();
scheduleAisHistoryRender();
- };
- aisWindow.restoreAisHistory = function(messages) {
- aisWindow.onServerAisBatch?.(messages);
- };
- aisWindow.pruneAisHistoryView = function() {
+ }
+ function pruneAisHistoryView() {
pruneAisMessageHistory();
updateAisBar();
renderAisHistory();
- };
+ }
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => {
void (async () => {
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await aisWindow.postPath?.("/clear_ais_decode");
- aisWindow.resetAisHistoryView?.();
+ resetAisHistoryView();
} catch (e) {
console.error("AIS history clear failed", e);
}
@@ -321,10 +318,17 @@
renderAisHistory();
});
}
- aisWindow.onServerAis = function(msg) {
+ function onServerAis(msg) {
if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg));
- };
+ }
updateAisSummary();
- aisWindow._trxDrainPendingDecode?.("ais");
+ window.trxPluginRuntime.registerDecoder({
+ id: "ais",
+ onMessage: onServerAis,
+ onBatch: onServerAisBatch,
+ restore: onServerAisBatch,
+ reset: resetAisHistoryView,
+ prune: pruneAisHistoryView
+ });
})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
index 948ef53e..e4bdb127 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
@@ -488,10 +488,10 @@ function currentDecodeHistoryRetentionMs() {
}
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
window.applyDecodeHistoryRetention = function() {
- if (typeof window.pruneAprsHistoryView === "function") window.pruneAprsHistoryView();
- if (typeof window.pruneHfAprsHistoryView === "function") window.pruneHfAprsHistoryView();
- if (typeof window.pruneAisHistoryView === "function") window.pruneAisHistoryView();
- if (typeof window.pruneVdesHistoryView === "function") window.pruneVdesHistoryView();
+ window.trxPluginRuntime.prune("aprs");
+ window.trxPluginRuntime.prune("hf_aprs");
+ window.trxPluginRuntime.prune("ais");
+ window.trxPluginRuntime.prune("vdes");
if (typeof window.pruneFt8HistoryView === "function") window.pruneFt8HistoryView();
if (typeof window.pruneWsprHistoryView === "function") window.pruneWsprHistoryView();
};
@@ -529,37 +529,6 @@ function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying
const decodeHistoryTextDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
let decodeHistoryReplayActive = false;
let decodeMapSyncPending = false;
-const _pendingDecodeHistory = {};
-const _pendingDecodeLive = {};
-window._trxDrainPendingDecode = function(kind) {
- const historyKey = {
- ais: "restoreAisHistory",
- vdes: "restoreVdesHistory",
- aprs: "restoreAprsHistory",
- hf_aprs: "restoreHfAprsHistory"
- }[kind];
- if (historyKey && _pendingDecodeHistory[kind] && window[historyKey]) {
- const msgs = _pendingDecodeHistory[kind];
- delete _pendingDecodeHistory[kind];
- window[historyKey](msgs);
- }
- const liveKey = {
- ais: "onServerAis",
- vdes: "onServerVdes",
- aprs: "onServerAprs",
- hf_aprs: "onServerHfAprs"
- }[kind];
- if (liveKey && _pendingDecodeLive[kind] && window[liveKey]) {
- const msgs = _pendingDecodeLive[kind];
- delete _pendingDecodeLive[kind];
- for (const msg of msgs) {
- try {
- window[liveKey](msg);
- } catch (_) {
- }
- }
- }
-};
function markDecodeMapSyncPending() {
decodeMapSyncPending = true;
}
@@ -5888,21 +5857,8 @@ function updateDecodeStatus(text) {
}
}
function dispatchDecodeMessage(msg, skipStats) {
- if (msg.type === "ais") {
- if (window.onServerAis) window.onServerAis(msg);
- else (_pendingDecodeLive.ais = _pendingDecodeLive.ais || []).push(msg);
- }
- if (msg.type === "vdes") {
- if (window.onServerVdes) window.onServerVdes(msg);
- else (_pendingDecodeLive.vdes = _pendingDecodeLive.vdes || []).push(msg);
- }
- if (msg.type === "aprs") {
- if (window.onServerAprs) window.onServerAprs(msg);
- else (_pendingDecodeLive.aprs = _pendingDecodeLive.aprs || []).push(msg);
- }
- if (msg.type === "hf_aprs") {
- if (window.onServerHfAprs) window.onServerHfAprs(msg);
- else (_pendingDecodeLive.hf_aprs = _pendingDecodeLive.hf_aprs || []).push(msg);
+ if (msg.type === "ais" || msg.type === "vdes" || msg.type === "aprs" || msg.type === "hf_aprs") {
+ window.trxPluginRuntime.dispatch(msg.type, msg);
}
if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg);
@@ -5929,20 +5885,8 @@ function dispatchDecodeBatch(batch) {
const type = String(batch[0]?.type || "");
const uniformType = batch.every((msg) => String(msg?.type || "") === type);
if (uniformType) {
- if (type === "ais" && window.onServerAisBatch) {
- window.onServerAisBatch(batch);
- return;
- }
- if (type === "vdes" && window.onServerVdesBatch) {
- window.onServerVdesBatch(batch);
- return;
- }
- if (type === "aprs" && window.onServerAprsBatch) {
- window.onServerAprsBatch(batch);
- return;
- }
- if (type === "hf_aprs" && window.onServerHfAprsBatch) {
- window.onServerHfAprsBatch(batch);
+ if (type === "ais" || type === "vdes" || type === "aprs" || type === "hf_aprs") {
+ window.trxPluginRuntime.dispatchBatch(type, batch);
return;
}
if (type === "ft8" && window.onServerFt8Batch) {
@@ -6010,36 +5954,8 @@ function restoreDecodeHistoryGroup(kind, messages) {
}
window.trx.modules.map?.scheduleStatsRender();
}
- if (kind === "ais") {
- if (window.restoreAisHistory) {
- window.restoreAisHistory(messages);
- } else {
- _pendingDecodeHistory.ais = (_pendingDecodeHistory.ais || []).concat(messages);
- }
- return;
- }
- if (kind === "vdes") {
- if (window.restoreVdesHistory) {
- window.restoreVdesHistory(messages);
- } else {
- _pendingDecodeHistory.vdes = (_pendingDecodeHistory.vdes || []).concat(messages);
- }
- return;
- }
- if (kind === "aprs") {
- if (window.restoreAprsHistory) {
- window.restoreAprsHistory(messages);
- } else {
- _pendingDecodeHistory.aprs = (_pendingDecodeHistory.aprs || []).concat(messages);
- }
- return;
- }
- if (kind === "hf_aprs") {
- if (window.restoreHfAprsHistory) {
- window.restoreHfAprsHistory(messages);
- } else {
- _pendingDecodeHistory.hf_aprs = (_pendingDecodeHistory.hf_aprs || []).concat(messages);
- }
+ if (kind === "ais" || kind === "vdes" || kind === "aprs" || kind === "hf_aprs") {
+ window.trxPluginRuntime.restore(kind, messages);
return;
}
if (kind === "cw" && window.restoreCwHistory) {
@@ -6074,11 +5990,11 @@ function connectDecode() {
terminateDecodeHistoryWorker();
decodeHistoryReplayActive = false;
decodeMapSyncPending = false;
- for (const k in _pendingDecodeHistory) delete _pendingDecodeHistory[k];
- for (const k in _pendingDecodeLive) delete _pendingDecodeLive[k];
- if (window.resetAisHistoryView) window.resetAisHistoryView();
- if (window.resetVdesHistoryView) window.resetVdesHistoryView();
- if (window.resetAprsHistoryView) window.resetAprsHistoryView();
+ window.trxPluginRuntime.clearQueued();
+ window.trxPluginRuntime.reset("ais");
+ window.trxPluginRuntime.reset("vdes");
+ window.trxPluginRuntime.reset("aprs");
+ window.trxPluginRuntime.reset("hf_aprs");
if (window.resetCwHistoryView) window.resetCwHistoryView();
if (window.resetFt8HistoryView) window.resetFt8HistoryView();
if (window.resetFt4HistoryView) window.resetFt4HistoryView();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js
index 444d1036..dbb05b92 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js
@@ -291,7 +291,7 @@
}
aprsWindow.updateAprsBar = updateAprsBar;
aprsWindow.clearAprsBar = function() {
- aprsWindow.resetAprsHistoryView?.();
+ resetAprsHistoryView();
};
aprsWindow.closeAprsBar = function() {
aprsBarDismissedAtMs = Date.now();
@@ -300,18 +300,18 @@
aprsBarOverlay.innerHTML = "";
}
};
- aprsWindow.resetAprsHistoryView = function() {
+ function resetAprsHistoryView() {
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
aprsPacketHistory = [];
updateAprsBar();
renderAprsHistory();
aprsWindow.clearMapMarkersByType?.("aprs");
- };
- aprsWindow.pruneAprsHistoryView = function() {
+ }
+ function pruneAprsHistoryView() {
pruneAprsPacketHistory();
updateAprsBar();
renderAprsHistory();
- };
+ }
function addAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
@@ -327,7 +327,7 @@
function normalizeServerAprsPacket(pkt) {
return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null);
}
- aprsWindow.onServerAprsBatch = function(packets) {
+ function onServerAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized = [];
@@ -348,16 +348,13 @@
pruneAprsPacketHistory();
if (hasCrcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
- };
- aprsWindow.restoreAprsHistory = function(packets) {
- aprsWindow.onServerAprsBatch?.(packets);
- };
+ }
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => {
void (async () => {
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await aprsWindow.postPath?.("/clear_aprs_decode");
- aprsWindow.resetAprsHistoryView?.();
+ resetAprsHistoryView();
} catch (e) {
console.error("APRS history clear failed", e);
}
@@ -395,10 +392,17 @@
renderAprsHistory();
});
}
- aprsWindow.onServerAprs = function(pkt) {
+ function onServerAprs(pkt) {
if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
- };
+ }
renderAprsHistory();
- aprsWindow._trxDrainPendingDecode?.("aprs");
+ window.trxPluginRuntime.registerDecoder({
+ id: "aprs",
+ onMessage: onServerAprs,
+ onBatch: onServerAprsBatch,
+ restore: onServerAprsBatch,
+ reset: resetAprsHistoryView,
+ prune: pruneAprsHistoryView
+ });
})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js
index 90def677..7b9cead2 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js
@@ -253,15 +253,15 @@
updateHfAprsSummary();
updateHfAprsChipState();
}
- hfAprsWindow.resetHfAprsHistoryView = function() {
+ function resetHfAprsHistoryView() {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
- };
- hfAprsWindow.pruneHfAprsHistoryView = function() {
+ }
+ function pruneHfAprsHistoryView() {
pruneHfAprsPacketHistory();
renderHfAprsHistory();
- };
+ }
function addHfAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
@@ -273,7 +273,7 @@
function normalizeServerHfAprsPacket(pkt) {
return normalizeAprsPacket(pkt, hfAprsWindow.getDecodeRigMeta?.() ?? null);
}
- hfAprsWindow.onServerHfAprsBatch = function(packets) {
+ function onServerHfAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized = [];
@@ -288,10 +288,7 @@
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
pruneHfAprsPacketHistory();
scheduleHfAprsHistoryRender();
- };
- hfAprsWindow.restoreHfAprsHistory = function(packets) {
- hfAprsWindow.onServerHfAprsBatch?.(packets);
- };
+ }
var hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
hfAprsDecodeToggleBtn?.addEventListener("click", () => {
void (async () => {
@@ -308,7 +305,7 @@
if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await hfAprsWindow.postPath?.("/clear_hf_aprs_decode");
- hfAprsWindow.resetHfAprsHistoryView?.();
+ resetHfAprsHistoryView();
} catch (e) {
console.error("HF APRS history clear failed", e);
}
@@ -346,10 +343,17 @@
renderHfAprsHistory();
});
}
- hfAprsWindow.onServerHfAprs = function(pkt) {
+ function onServerHfAprs(pkt) {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
- };
+ }
renderHfAprsHistory();
- hfAprsWindow._trxDrainPendingDecode?.("hf_aprs");
+ window.trxPluginRuntime.registerDecoder({
+ id: "hf_aprs",
+ onMessage: onServerHfAprs,
+ onBatch: onServerHfAprsBatch,
+ restore: onServerHfAprsBatch,
+ reset: resetHfAprsHistoryView,
+ prune: pruneHfAprsHistoryView
+ });
})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-runtime.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-runtime.js
new file mode 100644
index 00000000..7f597432
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-runtime.js
@@ -0,0 +1,80 @@
+"use strict";
+const decoders = /* @__PURE__ */ new Map();
+const queued = /* @__PURE__ */ new Map();
+const MAX_QUEUED_ACTIONS_PER_DECODER = 512;
+function enqueue(id, action) {
+ const actions = queued.get(id) ?? [];
+ actions.push(action);
+ if (actions.length > MAX_QUEUED_ACTIONS_PER_DECODER) actions.splice(0, actions.length - MAX_QUEUED_ACTIONS_PER_DECODER);
+ queued.set(id, actions);
+}
+function deliver(plugin, action) {
+ if (action.kind === "message" && plugin.onMessage) {
+ plugin.onMessage(action.payload);
+ return true;
+ }
+ if (action.kind === "batch" && plugin.onBatch) {
+ plugin.onBatch(action.payload);
+ return true;
+ }
+ if (action.kind === "restore" && plugin.restore) {
+ plugin.restore(action.payload);
+ return true;
+ }
+ return false;
+}
+function dispatchOrQueue(id, action) {
+ const plugin = decoders.get(id);
+ if (!plugin) {
+ enqueue(id, action);
+ return false;
+ }
+ if (!deliver(plugin, action)) {
+ if (action.kind === "batch" && plugin.onMessage) {
+ for (const message of action.payload) plugin.onMessage(message);
+ return true;
+ }
+ if (action.kind === "restore" && plugin.onBatch) {
+ plugin.onBatch(action.payload);
+ return true;
+ }
+ return false;
+ }
+ return true;
+}
+const runtime = {
+ registerDecoder(plugin) {
+ if (decoders.has(plugin.id)) throw new Error(`Decoder plugin already registered: ${plugin.id}`);
+ const erased = plugin;
+ decoders.set(plugin.id, erased);
+ const pending = queued.get(plugin.id) ?? [];
+ queued.delete(plugin.id);
+ for (const action of pending) deliver(erased, action);
+ return () => {
+ if (decoders.get(plugin.id) === erased) decoders.delete(plugin.id);
+ };
+ },
+ dispatch: (id, message) => dispatchOrQueue(id, { kind: "message", payload: message }),
+ dispatchBatch: (id, messages) => dispatchOrQueue(id, { kind: "batch", payload: messages }),
+ restore: (id, messages) => dispatchOrQueue(id, { kind: "restore", payload: messages }),
+ reset(id) {
+ const plugin = decoders.get(id);
+ if (!plugin?.reset) return false;
+ plugin.reset();
+ return true;
+ },
+ resetAll() {
+ for (const plugin of decoders.values()) plugin.reset?.();
+ },
+ prune(id) {
+ const plugin = decoders.get(id);
+ if (!plugin?.prune) return false;
+ plugin.prune();
+ return true;
+ },
+ clearQueued() {
+ queued.clear();
+ },
+ hasDecoder: (id) => decoders.has(id)
+};
+window.trxPluginRuntime = runtime;
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js
index abad6484..19338739 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js
@@ -165,14 +165,14 @@
}
vdesWindow.updateVdesBar = updateVdesBar;
vdesWindow.clearVdesBar = function() {
- vdesWindow.resetVdesHistoryView?.();
+ resetVdesHistoryView();
};
- vdesWindow.resetVdesHistoryView = function() {
+ function resetVdesHistoryView() {
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
vdesMessageHistory = [];
updateVdesBar();
renderVdesHistory();
- };
+ }
function renderVdesHistory() {
pruneVdesMessageHistory();
if (!vdesMessagesEl) {
@@ -205,7 +205,7 @@
rig_id: msg.rig_id || null
};
}
- vdesWindow.onServerVdesBatch = function(messages) {
+ function onServerVdesBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized = [];
@@ -228,16 +228,13 @@
pruneVdesMessageHistory();
scheduleVdesBarUpdate();
scheduleVdesHistoryRender();
- };
- vdesWindow.restoreVdesHistory = function(messages) {
- vdesWindow.onServerVdesBatch?.(messages);
- };
+ }
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => {
void (async () => {
if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await vdesWindow.postPath?.("/clear_vdes_decode");
- vdesWindow.resetVdesHistoryView?.();
+ resetVdesHistoryView();
} catch (e) {
console.error("VDES history clear failed", e);
}
@@ -249,19 +246,26 @@
renderVdesHistory();
});
}
- vdesWindow.onServerVdes = function(msg) {
+ function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
addVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
- };
- vdesWindow.pruneVdesHistoryView = function() {
+ }
+ function pruneVdesHistoryView() {
pruneVdesMessageHistory();
updateVdesBar();
renderVdesHistory();
- };
+ }
updateVdesSummary();
- vdesWindow._trxDrainPendingDecode?.("vdes");
+ window.trxPluginRuntime.registerDecoder({
+ id: "vdes",
+ onMessage: onServerVdes,
+ onBatch: onServerVdesBatch,
+ restore: onServerVdesBatch,
+ reset: resetVdesHistoryView,
+ prune: pruneVdesHistoryView
+ });
})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
index fa2df4cb..e56fbd00 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
@@ -1640,6 +1640,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs
index a0818503..3d7350c6 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs
@@ -20,6 +20,7 @@ await build({
"ui-core": path.join(sourceDir, "ui-core.ts"),
"map-core": path.join(sourceDir, "map-core.js"),
"plugin-loader": path.join(sourceDir, "plugin-loader.ts"),
+ "plugin-runtime": path.join(sourceDir, "plugin-runtime.ts"),
screenshot: path.join(sourceDir, "screenshot.ts"),
"webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"),
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js
index b41fdb46..de5058db 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js
@@ -567,10 +567,10 @@ function currentDecodeHistoryRetentionMs() {
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
window.applyDecodeHistoryRetention = function() {
- if (typeof window.pruneAprsHistoryView === "function") window.pruneAprsHistoryView();
- if (typeof window.pruneHfAprsHistoryView === "function") window.pruneHfAprsHistoryView();
- if (typeof window.pruneAisHistoryView === "function") window.pruneAisHistoryView();
- if (typeof window.pruneVdesHistoryView === "function") window.pruneVdesHistoryView();
+ window.trxPluginRuntime.prune("aprs");
+ window.trxPluginRuntime.prune("hf_aprs");
+ window.trxPluginRuntime.prune("ais");
+ window.trxPluginRuntime.prune("vdes");
if (typeof window.pruneFt8HistoryView === "function") window.pruneFt8HistoryView();
if (typeof window.pruneWsprHistoryView === "function") window.pruneWsprHistoryView();
};
@@ -615,41 +615,6 @@ const decodeHistoryTextDecoder = typeof TextDecoder === "function" ? new TextDec
let decodeHistoryReplayActive = false;
let decodeMapSyncPending = false;
-// --- Pending decode data buffers ---
-// Map-data plugins (ais.js, aprs.js, vdes.js, hf-aprs.js) are loaded eagerly
-// but dynamically-inserted scripts have no guaranteed execution order. If
-// decode history or live SSE messages arrive before the plugin handlers are
-// registered, buffer them here and let each plugin drain on init.
-const _pendingDecodeHistory = {};
-const _pendingDecodeLive = {};
-
-window._trxDrainPendingDecode = function(kind) {
- const historyKey = {
- ais: "restoreAisHistory",
- vdes: "restoreVdesHistory",
- aprs: "restoreAprsHistory",
- hf_aprs: "restoreHfAprsHistory",
- }[kind];
- if (historyKey && _pendingDecodeHistory[kind] && window[historyKey]) {
- const msgs = _pendingDecodeHistory[kind];
- delete _pendingDecodeHistory[kind];
- window[historyKey](msgs);
- }
- const liveKey = {
- ais: "onServerAis",
- vdes: "onServerVdes",
- aprs: "onServerAprs",
- hf_aprs: "onServerHfAprs",
- }[kind];
- if (liveKey && _pendingDecodeLive[kind] && window[liveKey]) {
- const msgs = _pendingDecodeLive[kind];
- delete _pendingDecodeLive[kind];
- for (const msg of msgs) {
- try { window[liveKey](msg); } catch (_) {}
- }
- }
-};
-
function markDecodeMapSyncPending() {
decodeMapSyncPending = true;
}
@@ -6250,10 +6215,9 @@ function updateDecodeStatus(text) {
}
}
function dispatchDecodeMessage(msg, skipStats) {
- if (msg.type === "ais") { if (window.onServerAis) window.onServerAis(msg); else (_pendingDecodeLive.ais = _pendingDecodeLive.ais || []).push(msg); }
- if (msg.type === "vdes") { if (window.onServerVdes) window.onServerVdes(msg); else (_pendingDecodeLive.vdes = _pendingDecodeLive.vdes || []).push(msg); }
- if (msg.type === "aprs") { if (window.onServerAprs) window.onServerAprs(msg); else (_pendingDecodeLive.aprs = _pendingDecodeLive.aprs || []).push(msg); }
- if (msg.type === "hf_aprs") { if (window.onServerHfAprs) window.onServerHfAprs(msg); else (_pendingDecodeLive.hf_aprs = _pendingDecodeLive.hf_aprs || []).push(msg); }
+ if (msg.type === "ais" || msg.type === "vdes" || msg.type === "aprs" || msg.type === "hf_aprs") {
+ window.trxPluginRuntime.dispatch(msg.type, msg);
+ }
if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg);
if (msg.type === "ft4" && window.onServerFt4) window.onServerFt4(msg);
@@ -6281,20 +6245,8 @@ function dispatchDecodeBatch(batch) {
const type = String(batch[0]?.type || "");
const uniformType = batch.every((msg) => String(msg?.type || "") === type);
if (uniformType) {
- if (type === "ais" && window.onServerAisBatch) {
- window.onServerAisBatch(batch);
- return;
- }
- if (type === "vdes" && window.onServerVdesBatch) {
- window.onServerVdesBatch(batch);
- return;
- }
- if (type === "aprs" && window.onServerAprsBatch) {
- window.onServerAprsBatch(batch);
- return;
- }
- if (type === "hf_aprs" && window.onServerHfAprsBatch) {
- window.onServerHfAprsBatch(batch);
+ if (type === "ais" || type === "vdes" || type === "aprs" || type === "hf_aprs") {
+ window.trxPluginRuntime.dispatchBatch(type, batch);
return;
}
if (type === "ft8" && window.onServerFt8Batch) {
@@ -6366,24 +6318,8 @@ function restoreDecodeHistoryGroup(kind, messages) {
}
window.trx.modules.map?.scheduleStatsRender();
}
- if (kind === "ais") {
- if (window.restoreAisHistory) { window.restoreAisHistory(messages); }
- else { _pendingDecodeHistory.ais = (_pendingDecodeHistory.ais || []).concat(messages); }
- return;
- }
- if (kind === "vdes") {
- if (window.restoreVdesHistory) { window.restoreVdesHistory(messages); }
- else { _pendingDecodeHistory.vdes = (_pendingDecodeHistory.vdes || []).concat(messages); }
- return;
- }
- if (kind === "aprs") {
- if (window.restoreAprsHistory) { window.restoreAprsHistory(messages); }
- else { _pendingDecodeHistory.aprs = (_pendingDecodeHistory.aprs || []).concat(messages); }
- return;
- }
- if (kind === "hf_aprs") {
- if (window.restoreHfAprsHistory) { window.restoreHfAprsHistory(messages); }
- else { _pendingDecodeHistory.hf_aprs = (_pendingDecodeHistory.hf_aprs || []).concat(messages); }
+ if (kind === "ais" || kind === "vdes" || kind === "aprs" || kind === "hf_aprs") {
+ window.trxPluginRuntime.restore(kind, messages);
return;
}
if (kind === "cw" && window.restoreCwHistory) {
@@ -6417,12 +6353,11 @@ function connectDecode() {
terminateDecodeHistoryWorker();
decodeHistoryReplayActive = false;
decodeMapSyncPending = false;
- // Clear any pending buffers from a previous connection cycle.
- for (const k in _pendingDecodeHistory) delete _pendingDecodeHistory[k];
- for (const k in _pendingDecodeLive) delete _pendingDecodeLive[k];
- if (window.resetAisHistoryView) window.resetAisHistoryView();
- if (window.resetVdesHistoryView) window.resetVdesHistoryView();
- if (window.resetAprsHistoryView) window.resetAprsHistoryView();
+ window.trxPluginRuntime.clearQueued();
+ window.trxPluginRuntime.reset("ais");
+ window.trxPluginRuntime.reset("vdes");
+ window.trxPluginRuntime.reset("aprs");
+ window.trxPluginRuntime.reset("hf_aprs");
if (window.resetCwHistoryView) window.resetCwHistoryView();
if (window.resetFt8HistoryView) window.resetFt8HistoryView();
if (window.resetFt4HistoryView) window.resetFt4HistoryView();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-runtime.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-runtime.ts
new file mode 100644
index 00000000..836c3e92
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-runtime.ts
@@ -0,0 +1,88 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+import type { DecoderPlugin, PluginRuntimeWindow, TrxPluginRuntime } from "./plugins/runtime-contract";
+
+type QueuedAction =
+ | { kind: "message"; payload: unknown }
+ | { kind: "batch" | "restore"; payload: unknown[] };
+
+const decoders = new Map();
+const queued = new Map();
+const MAX_QUEUED_ACTIONS_PER_DECODER = 512;
+
+function enqueue(id: string, action: QueuedAction): void {
+ const actions = queued.get(id) ?? [];
+ actions.push(action);
+ if (actions.length > MAX_QUEUED_ACTIONS_PER_DECODER) actions.splice(0, actions.length - MAX_QUEUED_ACTIONS_PER_DECODER);
+ queued.set(id, actions);
+}
+
+function deliver(plugin: DecoderPlugin, action: QueuedAction): boolean {
+ if (action.kind === "message" && plugin.onMessage) {
+ plugin.onMessage(action.payload);
+ return true;
+ }
+ if (action.kind === "batch" && plugin.onBatch) {
+ plugin.onBatch(action.payload);
+ return true;
+ }
+ if (action.kind === "restore" && plugin.restore) {
+ plugin.restore(action.payload);
+ return true;
+ }
+ return false;
+}
+
+function dispatchOrQueue(id: string, action: QueuedAction): boolean {
+ const plugin = decoders.get(id);
+ if (!plugin) {
+ enqueue(id, action);
+ return false;
+ }
+ if (!deliver(plugin, action)) {
+ if (action.kind === "batch" && plugin.onMessage) {
+ for (const message of action.payload) plugin.onMessage(message);
+ return true;
+ }
+ if (action.kind === "restore" && plugin.onBatch) {
+ plugin.onBatch(action.payload);
+ return true;
+ }
+ return false;
+ }
+ return true;
+}
+
+const runtime: TrxPluginRuntime = {
+ registerDecoder(plugin: DecoderPlugin): () => void {
+ if (decoders.has(plugin.id)) throw new Error(`Decoder plugin already registered: ${plugin.id}`);
+ const erased = plugin as DecoderPlugin;
+ decoders.set(plugin.id, erased);
+ const pending = queued.get(plugin.id) ?? [];
+ queued.delete(plugin.id);
+ for (const action of pending) deliver(erased, action);
+ return () => { if (decoders.get(plugin.id) === erased) decoders.delete(plugin.id); };
+ },
+ dispatch: (id, message) => dispatchOrQueue(id, { kind: "message", payload: message }),
+ dispatchBatch: (id, messages) => dispatchOrQueue(id, { kind: "batch", payload: messages }),
+ restore: (id, messages) => dispatchOrQueue(id, { kind: "restore", payload: messages }),
+ reset(id) {
+ const plugin = decoders.get(id);
+ if (!plugin?.reset) return false;
+ plugin.reset();
+ return true;
+ },
+ resetAll() { for (const plugin of decoders.values()) plugin.reset?.(); },
+ prune(id) {
+ const plugin = decoders.get(id);
+ if (!plugin?.prune) return false;
+ plugin.prune();
+ return true;
+ },
+ clearQueued() { queued.clear(); },
+ hasDecoder: (id) => decoders.has(id),
+};
+
+(window as unknown as PluginRuntimeWindow).trxPluginRuntime = runtime;
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts
index 5b5ba892..342fd0d5 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts
@@ -4,6 +4,8 @@
export {};
+import type { PluginRuntimeWindow } from "./runtime-contract";
+
interface AisMessage {
rig_id?: string | null;
channel?: string | null;
@@ -36,12 +38,6 @@ interface AisBridge {
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise };
updateAisBar?: () => void;
clearAisBar?: () => void;
- resetAisHistoryView?: () => void;
- onServerAisBatch?: (messages: AisMessage[]) => void;
- restoreAisHistory?: (messages: AisMessage[]) => void;
- pruneAisHistoryView?: () => void;
- onServerAis?: (message: AisMessage) => void;
- _trxDrainPendingDecode?: (decoder: string) => void;
}
const aisWindow = window as unknown as AisBridge;
const escapeAisHtml = (input: string): string => aisWindow.escapeMapHtml?.(input) ?? input
@@ -324,16 +320,16 @@ function updateAisBar() {
}
aisWindow.updateAisBar = updateAisBar;
aisWindow.clearAisBar = function() {
- aisWindow.resetAisHistoryView?.();
+ resetAisHistoryView();
};
-aisWindow.resetAisHistoryView = function() {
+function resetAisHistoryView(): void {
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
aisMessageHistory = [];
updateAisBar();
renderAisHistory();
aisWindow.clearMapMarkersByType?.("ais");
-};
+}
function renderAisHistory() {
pruneAisMessageHistory();
@@ -375,7 +371,7 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage {
};
}
-aisWindow.onServerAisBatch = function(messages: AisMessage[]) {
+function onServerAisBatch(messages: AisMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized: AisMessage[] = [];
@@ -398,23 +394,19 @@ aisWindow.onServerAisBatch = function(messages: AisMessage[]) {
pruneAisMessageHistory();
scheduleAisBarUpdate();
scheduleAisHistoryRender();
-};
+}
-aisWindow.restoreAisHistory = function(messages: AisMessage[]) {
- aisWindow.onServerAisBatch?.(messages);
-};
-
-aisWindow.pruneAisHistoryView = function() {
+function pruneAisHistoryView(): void {
pruneAisMessageHistory();
updateAisBar();
renderAisHistory();
-};
+}
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => { void (async () => {
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await aisWindow.postPath?.("/clear_ais_decode");
- aisWindow.resetAisHistoryView?.();
+ resetAisHistoryView();
} catch (e) {
console.error("AIS history clear failed", e);
}
@@ -427,10 +419,17 @@ if (aisFilterInput) {
});
}
-aisWindow.onServerAis = function(msg: AisMessage) {
+function onServerAis(msg: AisMessage): void {
if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg));
-};
+}
updateAisSummary();
-aisWindow._trxDrainPendingDecode?.("ais");
+(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder({
+ id: "ais",
+ onMessage: onServerAis,
+ onBatch: onServerAisBatch,
+ restore: onServerAisBatch,
+ reset: resetAisHistoryView,
+ prune: pruneAisHistoryView,
+});
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts
index e6e15a28..654e847e 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts
@@ -14,6 +14,7 @@ import {
type AprsPacket,
type AprsTypeFilter,
} from "./aprs-shared";
+import type { PluginRuntimeWindow } from "./runtime-contract";
interface AprsBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
@@ -31,12 +32,6 @@ interface AprsBridge {
updateAprsBar?: () => void;
clearAprsBar?: () => void;
closeAprsBar?: () => void;
- resetAprsHistoryView?: () => void;
- pruneAprsHistoryView?: () => void;
- onServerAprsBatch?: (packets: AprsPacket[]) => void;
- restoreAprsHistory?: (packets: AprsPacket[]) => void;
- onServerAprs?: (packet: AprsPacket) => void;
- _trxDrainPendingDecode?: (decoder: string) => void;
}
const aprsWindow = window as unknown as AprsBridge;
const escapeAprsHtml = (input: string): string => aprsWindow.escapeMapHtml?.(input) ?? input
@@ -288,7 +283,7 @@ function updateAprsBar() {
}
aprsWindow.updateAprsBar = updateAprsBar;
aprsWindow.clearAprsBar = function() {
- aprsWindow.resetAprsHistoryView?.();
+ resetAprsHistoryView();
};
aprsWindow.closeAprsBar = function() {
aprsBarDismissedAtMs = Date.now();
@@ -298,19 +293,19 @@ aprsWindow.closeAprsBar = function() {
}
};
-aprsWindow.resetAprsHistoryView = function() {
+function resetAprsHistoryView(): void {
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
aprsPacketHistory = [];
updateAprsBar();
renderAprsHistory();
aprsWindow.clearMapMarkersByType?.("aprs");
-};
+}
-aprsWindow.pruneAprsHistoryView = function() {
+function pruneAprsHistoryView(): void {
pruneAprsPacketHistory();
updateAprsBar();
renderAprsHistory();
-};
+}
function addAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
@@ -333,7 +328,7 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null);
}
-aprsWindow.onServerAprsBatch = function(packets: AprsPacket[]) {
+function onServerAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = [];
@@ -354,17 +349,13 @@ aprsWindow.onServerAprsBatch = function(packets: AprsPacket[]) {
pruneAprsPacketHistory();
if (hasCrcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
-};
-
-aprsWindow.restoreAprsHistory = function(packets: AprsPacket[]) {
- aprsWindow.onServerAprsBatch?.(packets);
-};
+}
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => { void (async () => {
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await aprsWindow.postPath?.("/clear_aprs_decode");
- aprsWindow.resetAprsHistoryView?.();
+ resetAprsHistoryView();
} catch (e) {
console.error("APRS history clear failed", e);
}
@@ -408,10 +399,17 @@ if (aprsFilterInput) {
}
// --- Server-side APRS decode handler ---
-aprsWindow.onServerAprs = function(pkt: AprsPacket) {
+function onServerAprs(pkt: AprsPacket): void {
if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
-};
+}
renderAprsHistory();
-aprsWindow._trxDrainPendingDecode?.("aprs");
+(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder({
+ id: "aprs",
+ onMessage: onServerAprs,
+ onBatch: onServerAprsBatch,
+ restore: onServerAprsBatch,
+ reset: resetAprsHistoryView,
+ prune: pruneAprsHistoryView,
+});
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts
index 47109b5b..2fc3425e 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts
@@ -14,6 +14,7 @@ import {
type AprsPacket,
type AprsTypeFilter,
} from "./aprs-shared";
+import type { PluginRuntimeWindow } from "./runtime-contract";
interface HfAprsBridge {
getDecodeHistoryRetentionMs?: () => number;
@@ -28,12 +29,6 @@ interface HfAprsBridge {
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise;
postPath?: (path: string) => Promise;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise };
- resetHfAprsHistoryView?: () => void;
- pruneHfAprsHistoryView?: () => void;
- onServerHfAprsBatch?: (packets: AprsPacket[]) => void;
- restoreHfAprsHistory?: (packets: AprsPacket[]) => void;
- onServerHfAprs?: (packet: AprsPacket) => void;
- _trxDrainPendingDecode?: (decoder: string) => void;
}
const hfAprsWindow = window as unknown as HfAprsBridge;
const escapeHfAprsHtml = (input: string): string => hfAprsWindow.escapeMapHtml?.(input) ?? input
@@ -247,16 +242,16 @@ function renderHfAprsHistory() {
updateHfAprsChipState();
}
-hfAprsWindow.resetHfAprsHistoryView = function() {
+function resetHfAprsHistoryView(): void {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
-};
+}
-hfAprsWindow.pruneHfAprsHistoryView = function() {
+function pruneHfAprsHistoryView(): void {
pruneHfAprsPacketHistory();
renderHfAprsHistory();
-};
+}
function addHfAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
@@ -273,7 +268,7 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
return normalizeAprsPacket(pkt, hfAprsWindow.getDecodeRigMeta?.() ?? null);
}
-hfAprsWindow.onServerHfAprsBatch = function(packets: AprsPacket[]) {
+function onServerHfAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = [];
@@ -288,11 +283,7 @@ hfAprsWindow.onServerHfAprsBatch = function(packets: AprsPacket[]) {
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
pruneHfAprsPacketHistory();
scheduleHfAprsHistoryRender();
-};
-
-hfAprsWindow.restoreHfAprsHistory = function(packets: AprsPacket[]) {
- hfAprsWindow.onServerHfAprsBatch?.(packets);
-};
+}
const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
hfAprsDecodeToggleBtn?.addEventListener("click", () => { void (async () => {
@@ -308,7 +299,7 @@ document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("cli
if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await hfAprsWindow.postPath?.("/clear_hf_aprs_decode");
- hfAprsWindow.resetHfAprsHistoryView?.();
+ resetHfAprsHistoryView();
} catch (e) {
console.error("HF APRS history clear failed", e);
}
@@ -352,10 +343,17 @@ if (hfAprsFilterInput) {
}
// --- Server-side HF APRS decode handler ---
-hfAprsWindow.onServerHfAprs = function(pkt: AprsPacket) {
+function onServerHfAprs(pkt: AprsPacket): void {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
-};
+}
renderHfAprsHistory();
-hfAprsWindow._trxDrainPendingDecode?.("hf_aprs");
+(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder({
+ id: "hf_aprs",
+ onMessage: onServerHfAprs,
+ onBatch: onServerHfAprsBatch,
+ restore: onServerHfAprsBatch,
+ reset: resetHfAprsHistoryView,
+ prune: pruneHfAprsHistoryView,
+});
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/runtime-contract.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/runtime-contract.ts
new file mode 100644
index 00000000..80712f51
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/runtime-contract.ts
@@ -0,0 +1,26 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+export interface DecoderPlugin {
+ readonly id: string;
+ onMessage?(message: TMessage): void;
+ onBatch?(messages: TMessage[]): void;
+ restore?(messages: TMessage[]): void;
+ reset?(): void;
+ prune?(): void;
+}
+
+export interface TrxPluginRuntime {
+ registerDecoder(plugin: DecoderPlugin): () => void;
+ dispatch(id: string, message: unknown): boolean;
+ dispatchBatch(id: string, messages: unknown[]): boolean;
+ restore(id: string, messages: unknown[]): boolean;
+ reset(id: string): boolean;
+ resetAll(): void;
+ prune(id: string): boolean;
+ clearQueued(): void;
+ hasDecoder(id: string): boolean;
+}
+
+export interface PluginRuntimeWindow { trxPluginRuntime: TrxPluginRuntime }
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts
index 25cbd75f..b931d905 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts
@@ -4,6 +4,8 @@
export {};
+import type { PluginRuntimeWindow } from "./runtime-contract";
+
interface VdesMessage {
rig_id?: string | null;
message_type?: number;
@@ -42,12 +44,6 @@ interface VdesBridge {
vdesMapAddPoint?: (message: VdesMessage) => void;
updateVdesBar?: () => void;
clearVdesBar?: () => void;
- resetVdesHistoryView?: () => void;
- onServerVdesBatch?: (messages: VdesMessage[]) => void;
- restoreVdesHistory?: (messages: VdesMessage[]) => void;
- onServerVdes?: (message: VdesMessage) => void;
- pruneVdesHistoryView?: () => void;
- _trxDrainPendingDecode?: (decoder: string) => void;
}
const vdesWindow = window as unknown as VdesBridge;
@@ -267,15 +263,15 @@ function updateVdesBar() {
}
vdesWindow.updateVdesBar = updateVdesBar;
vdesWindow.clearVdesBar = function() {
- vdesWindow.resetVdesHistoryView?.();
+ resetVdesHistoryView();
};
-vdesWindow.resetVdesHistoryView = function() {
+function resetVdesHistoryView(): void {
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
vdesMessageHistory = [];
updateVdesBar();
renderVdesHistory();
-};
+}
function renderVdesHistory() {
pruneVdesMessageHistory();
@@ -313,7 +309,7 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
};
}
-vdesWindow.onServerVdesBatch = function(messages: VdesMessage[]) {
+function onServerVdesBatch(messages: VdesMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized: VdesMessage[] = [];
@@ -336,17 +332,13 @@ vdesWindow.onServerVdesBatch = function(messages: VdesMessage[]) {
pruneVdesMessageHistory();
scheduleVdesBarUpdate();
scheduleVdesHistoryRender();
-};
-
-vdesWindow.restoreVdesHistory = function(messages: VdesMessage[]) {
- vdesWindow.onServerVdesBatch?.(messages);
-};
+}
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => { void (async () => {
if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await vdesWindow.postPath?.("/clear_vdes_decode");
- vdesWindow.resetVdesHistoryView?.();
+ resetVdesHistoryView();
} catch (e) {
console.error("VDES history clear failed", e);
}
@@ -359,20 +351,27 @@ if (vdesFilterInput) {
});
}
-vdesWindow.onServerVdes = function(msg: VdesMessage) {
+function onServerVdes(msg: VdesMessage): void {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
addVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
-};
+}
-vdesWindow.pruneVdesHistoryView = function() {
+function pruneVdesHistoryView(): void {
pruneVdesMessageHistory();
updateVdesBar();
renderVdesHistory();
-};
+}
updateVdesSummary();
-vdesWindow._trxDrainPendingDecode?.("vdes");
+(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder({
+ id: "vdes",
+ onMessage: onServerVdes,
+ onBatch: onServerVdesBatch,
+ restore: onServerVdesBatch,
+ reset: resetVdesHistoryView,
+ prune: pruneVdesHistoryView,
+});
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs
index 3a0e2248..4a12e643 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs
@@ -23,10 +23,13 @@ test("AIS entry forwards positioned vessels with normalized metadata", async ()
Map,
console,
});
+ const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const source = await readFile(new URL("../../assets/web/generated/ais.js", import.meta.url), "utf8");
+ new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
- window.onServerAis({ mmsi: 261000001, lat: 54.5, lon: 18.5, channel: "A", ts_ms: Date.now() });
+ window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, lat: 54.5, lon: 18.5, channel: "A", ts_ms: Date.now() });
+ assert.equal(window.onServerAis, undefined);
assert.equal(forwarded.length, 1);
assert.equal(forwarded[0].mmsi, 261000001);
assert.equal(forwarded[0].rig_id, null);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs
index 4571966f..a8b95aa9 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs
@@ -25,11 +25,14 @@ test("APRS entry normalizes positioned packets without remote symbol assets", as
Reflect,
console,
});
+ const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const source = await readFile(new URL("../../assets/web/generated/aprs.js", import.meta.url), "utf8");
assert.equal(source.includes("raw.githubusercontent.com"), false);
+ new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
- window.onServerAprs({ src_call: "SP0ABC", dest_call: "APRS", crc_ok: true, lat: 54.5, lon: 18.5, symbol_table: "/", symbol_code: ">" });
+ window.trxPluginRuntime.dispatch("aprs", { src_call: "SP0ABC", dest_call: "APRS", crc_ok: true, lat: 54.5, lon: 18.5, symbol_table: "/", symbol_code: ">" });
+ assert.equal(window.onServerAprs, undefined);
assert.equal(forwarded.length, 1);
assert.equal(forwarded[0][0], "SP0ABC");
assert.equal(forwarded[0][1], 54.5);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs
index 9c729b73..ab061222 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs
@@ -21,11 +21,13 @@ test("HF APRS entry uses shared typed normalization and local symbols", async ()
Reflect,
console,
});
+ const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const source = await readFile(new URL("../../assets/web/generated/hf-aprs.js", import.meta.url), "utf8");
assert.equal(source.includes("raw.githubusercontent.com"), false);
+ new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
- window.onServerHfAprs({ src_call: "SP0ABC", dest_call: "APRS", crc_ok: true, symbol_table: "/", symbol_code: ">" });
- assert.equal(typeof window.restoreHfAprsHistory, "function");
- assert.equal(typeof window.pruneHfAprsHistoryView, "function");
+ window.trxPluginRuntime.dispatch("hf_aprs", { src_call: "SP0ABC", dest_call: "APRS", crc_ok: true, symbol_table: "/", symbol_code: ">" });
+ assert.equal(window.onServerHfAprs, undefined);
+ assert.equal(window.trxPluginRuntime.hasDecoder("hf_aprs"), true);
});
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/plugin-runtime.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/plugin-runtime.test.mjs
new file mode 100644
index 00000000..b5b1ba81
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/plugin-runtime.test.mjs
@@ -0,0 +1,25 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+import vm from "node:vm";
+
+test("plugin runtime queues lazy decoder traffic and flushes in order on registration", async () => {
+ const window = {};
+ const context = vm.createContext({ window, Map, Error });
+ const source = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
+ new vm.Script(source).runInContext(context);
+
+ assert.equal(window.trxPluginRuntime.dispatch("late", { sequence: 1 }), false);
+ assert.equal(window.trxPluginRuntime.restore("late", [{ sequence: 2 }]), false);
+ const received = [];
+ window.trxPluginRuntime.registerDecoder({
+ id: "late",
+ onMessage: (message) => { received.push(["message", message.sequence]); },
+ restore: (messages) => { received.push(["restore", messages[0].sequence]); },
+ });
+ assert.deepEqual(received, [["message", 1], ["restore", 2]]);
+});
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs
index 35d30769..df7bd10a 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs
@@ -22,10 +22,13 @@ test("VDES entry normalizes and forwards positioned server messages", async () =
Array,
console,
});
+ const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const source = await readFile(new URL("../../assets/web/generated/vdes.js", import.meta.url), "utf8");
+ new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
- window.onServerVdes({ callsign: "SP0ABC", lat: 54.5, lon: 18.5, bit_len: 120, ts_ms: Date.now() });
+ window.trxPluginRuntime.dispatch("vdes", { callsign: "SP0ABC", lat: 54.5, lon: 18.5, bit_len: 120, ts_ms: Date.now() });
+ assert.equal(window.onServerVdes, undefined);
assert.equal(forwarded.length, 1);
assert.equal(forwarded[0].callsign, "SP0ABC");
assert.equal(forwarded[0].rig_id, null);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs
index 2632276c..63eb391f 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs
@@ -36,6 +36,11 @@ define_gz_cache!(
status::PLUGIN_LOADER_JS,
"plugin-loader.js"
);
+define_gz_cache!(
+ gz_plugin_runtime_js,
+ status::PLUGIN_RUNTIME_JS,
+ "plugin-runtime.js"
+);
define_gz_cache!(gz_screenshot_js, status::SCREENSHOT_JS, "screenshot.js");
define_gz_cache!(
gz_decode_history_worker_js,
@@ -204,6 +209,12 @@ pub(crate) async fn plugin_loader_js(req: HttpRequest) -> impl Responder {
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
+#[get("/plugin-runtime.js")]
+pub(crate) async fn plugin_runtime_js(req: HttpRequest) -> impl Responder {
+ let c = gz_plugin_runtime_js();
+ static_asset_response(&req, "application/javascript; charset=utf-8", c)
+}
+
#[get("/screenshot.js")]
pub(crate) async fn screenshot_js(req: HttpRequest) -> impl Responder {
let c = gz_screenshot_js();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
index 9c92f34b..033a2f0f 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
@@ -646,6 +646,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::ui_core_js)
.service(assets::map_core_js)
.service(assets::plugin_loader_js)
+ .service(assets::plugin_runtime_js)
.service(assets::screenshot_js)
.service(assets::decode_history_worker_js)
.service(assets::webgl_renderer_js)
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 ed88a73d..ac53300d 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
@@ -15,6 +15,7 @@ pub const APP_JS: &str = include_str!("../assets/web/generated/app.js");
pub const UI_CORE_JS: &str = include_str!("../assets/web/generated/ui-core.js");
pub const MAP_CORE_JS: &str = include_str!("../assets/web/generated/map-core.js");
pub const PLUGIN_LOADER_JS: &str = include_str!("../assets/web/generated/plugin-loader.js");
+pub const PLUGIN_RUNTIME_JS: &str = include_str!("../assets/web/generated/plugin-runtime.js");
pub const SCREENSHOT_JS: &str = include_str!("../assets/web/generated/screenshot.js");
pub const DECODE_HISTORY_WORKER_JS: &str =
include_str!("../assets/web/generated/decode-history-worker.js");
@@ -94,6 +95,14 @@ mod tests {
.find("/plugin-loader.js")
.expect("typed plugin loader is loaded");
assert!(plugin_loader < app, "plugin registry must load before app");
+ let plugin_runtime = html
+ .find("/plugin-runtime.js")
+ .expect("typed plugin runtime is loaded");
+ assert!(
+ plugin_runtime < plugin_loader,
+ "plugin runtime must load before the lazy loader"
+ );
+ assert!(PLUGIN_RUNTIME_JS.contains("registerDecoder"));
assert!(PLUGIN_LOADER_JS.contains("import(path)"));
assert!(html.contains("Scheduler controls"));
assert!(html.contains("Audio controls"));