diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js new file mode 100644 index 00000000..aab2aeb9 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js @@ -0,0 +1,59 @@ +"use strict"; +const pluginGroups = { + "digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"], + "map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"], + map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"], + statistics: ["/map-core.js"], + bookmarks: ["/bookmarks.js"], + recorder: [], + settings: ["/vchan.js", "/scheduler.js"] +}; +const loaded = /* @__PURE__ */ new Set(); +const loading = /* @__PURE__ */ new Map(); +const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js"]); +function loadLegacyScript(path) { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = path; + script.addEventListener("load", () => { + resolve(); + }, { once: true }); + script.addEventListener("error", () => { + reject(new Error(`Failed to load plugin script: ${path}`)); + }, { once: true }); + document.body.appendChild(script); + }); +} +async function loadPlugin(path) { + if (loaded.has(path)) return; + const pending = loading.get(path); + if (pending) return pending; + const request = (modulePlugins.has(path) ? import(path).then(() => void 0) : loadLegacyScript(path)).then(() => { + loaded.add(path); + loading.delete(path); + }).catch((error) => { + loading.delete(path); + throw new Error(`Failed to load plugin module: ${path}`, { cause: error }); + }); + loading.set(path, request); + return request; +} +async function loadPlugins(group) { + if (!(group in pluginGroups)) return; + for (const path of pluginGroups[group]) await loadPlugin(path); +} +function requestPlugins(group) { + void loadPlugins(group).catch((error) => { + console.error(error); + }); +} +const loaderWindow = window; +loaderWindow.loadEagerPlugins = async () => { + await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins)); +}; +loaderWindow.loadPluginsForTab = loadPlugins; +document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) return; + const tab = event.target.closest("[data-tab]")?.dataset.tab; + if (tab) requestPlugins(tab); +}); 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 1ab75cc4..fa2df4cb 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,74 +1640,8 @@ 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 0cb620bb..53e313bd 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 @@ -19,6 +19,7 @@ await build({ app: path.join(sourceDir, "app.js"), "ui-core": path.join(sourceDir, "ui-core.ts"), "map-core": path.join(sourceDir, "map-core.js"), + "plugin-loader": path.join(sourceDir, "plugin-loader.ts"), screenshot: path.join(sourceDir, "screenshot.ts"), "webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"), ais: path.join(sourceDir, "plugins", "ais.js"), diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts new file mode 100644 index 00000000..cdf6c769 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings"; + +const pluginGroups: Readonly> = { + "digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"], + "map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"], + map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"], + statistics: ["/map-core.js"], + bookmarks: ["/bookmarks.js"], + recorder: [], + settings: ["/vchan.js", "/scheduler.js"], +}; + +const loaded = new Set(); +const loading = new Map>(); +const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js"]); + +function loadLegacyScript(path: string): Promise { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = path; + script.addEventListener("load", () => { resolve(); }, { once: true }); + script.addEventListener("error", () => { reject(new Error(`Failed to load plugin script: ${path}`)); }, { once: true }); + document.body.appendChild(script); + }); +} + +async function loadPlugin(path: string): Promise { + if (loaded.has(path)) return; + const pending = loading.get(path); + if (pending) return pending; + const request = (modulePlugins.has(path) ? import(path).then(() => undefined) : loadLegacyScript(path)).then(() => { + loaded.add(path); + loading.delete(path); + }).catch((error: unknown) => { + loading.delete(path); + throw new Error(`Failed to load plugin module: ${path}`, { cause: error }); + }); + loading.set(path, request); + return request; +} + +async function loadPlugins(group: string): Promise { + if (!(group in pluginGroups)) return; + for (const path of pluginGroups[group as PluginGroup]) await loadPlugin(path); +} + +function requestPlugins(group: string): void { + void loadPlugins(group).catch((error: unknown) => { console.error(error); }); +} + +const loaderWindow = window as typeof window & { + loadEagerPlugins?: () => Promise; + loadPluginsForTab?: (tab: string) => Promise; +}; +loaderWindow.loadEagerPlugins = async () => { + await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins)); +}; +loaderWindow.loadPluginsForTab = loadPlugins; + +document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) return; + const tab = event.target.closest("[data-tab]")?.dataset.tab; + if (tab) requestPlugins(tab); +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs index 6fa1f158..3d02b278 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs @@ -11,6 +11,8 @@ const indexPath = new URL("../../assets/web/index.html", import.meta.url); test("index loads the shared UI before the application", async () => { const html = await readFile(indexPath, "utf8"); assert.ok(html.indexOf("/ui-core.js") < html.indexOf("/app.js")); + assert.ok(html.indexOf("/plugin-loader.js") < html.indexOf("/app.js")); + assert.equal(html.includes("var pluginScripts"), false); }); test("startup has no remote script or stylesheet dependencies", async () => { 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 bbd67c9a..2632276c 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 @@ -31,6 +31,11 @@ define_gz_cache!(gz_themes_css, status::THEMES_CSS, "themes.css"); define_gz_cache!(gz_app_js, status::APP_JS, "app.js"); define_gz_cache!(gz_ui_core_js, status::UI_CORE_JS, "ui-core.js"); define_gz_cache!(gz_map_core_js, status::MAP_CORE_JS, "map-core.js"); +define_gz_cache!( + gz_plugin_loader_js, + status::PLUGIN_LOADER_JS, + "plugin-loader.js" +); define_gz_cache!(gz_screenshot_js, status::SCREENSHOT_JS, "screenshot.js"); define_gz_cache!( gz_decode_history_worker_js, @@ -193,6 +198,12 @@ pub(crate) async fn map_core_js(req: HttpRequest) -> impl Responder { static_asset_response(&req, "application/javascript; charset=utf-8", c) } +#[get("/plugin-loader.js")] +pub(crate) async fn plugin_loader_js(req: HttpRequest) -> impl Responder { + let c = gz_plugin_loader_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 81019b68..9c92f34b 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 @@ -645,6 +645,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(assets::app_js) .service(assets::ui_core_js) .service(assets::map_core_js) + .service(assets::plugin_loader_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 adbf1db6..ed88a73d 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 @@ -14,6 +14,7 @@ pub const THEMES_CSS: &str = include_str!("../assets/web/themes.css"); 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 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"); @@ -88,7 +89,12 @@ mod tests { ui_core < app, "UI primitives must load before application code" ); - assert!(UI_CORE_JS.contains("window.trxUi")); + assert!(UI_CORE_JS.contains("trxUi")); + let plugin_loader = html + .find("/plugin-loader.js") + .expect("typed plugin loader is loaded"); + assert!(plugin_loader < app, "plugin registry must load before app"); + assert!(PLUGIN_LOADER_JS.contains("import(path)")); assert!(html.contains("Scheduler controls")); assert!(html.contains("Audio controls")); }