build: consolidate typed frontend module graph

This commit is contained in:
sjg
2026-08-01 21:54:00 +02:00
parent 8aeca613aa
commit d929ad3bde
31 changed files with 9065 additions and 9152 deletions
@@ -15,43 +15,12 @@ await rm(outputDir, { recursive: true, force: true });
await build({
entryPoints: {
"api-client": path.join(sourceDir, "api", "client.ts"),
"ui-core": path.join(sourceDir, "ui-core.ts"),
"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"),
},
outdir: outputDir,
bundle: false,
target: "es2022",
sourcemap: false,
legalComments: "inline",
charset: "utf8",
logLevel: "info",
});
await build({
entryPoints: { app: path.join(sourceDir, "bootstrap.ts") },
outdir: outputDir,
bundle: true,
format: "iife",
platform: "browser",
target: "es2022",
sourcemap: false,
legalComments: "inline",
charset: "utf8",
logLevel: "info",
});
await build({
entryPoints: {
app: path.join(sourceDir, "bootstrap.ts"),
ft2: path.join(sourceDir, "plugins", "ft2.ts"),
ft4: path.join(sourceDir, "plugins", "ft4.ts"),
wspr: path.join(sourceDir, "plugins", "wspr.ts"),
cw: path.join(sourceDir, "plugins", "cw.ts"),
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
"leaflet-ais-tracksymbol": path.join(sourceDir, "leaflet-ais-tracksymbol.ts"),
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
@@ -64,6 +33,7 @@ await build({
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
"map-core": path.join(sourceDir, "map-core.ts"),
screenshot: path.join(sourceDir, "screenshot.ts"),
},
outdir: outputDir,
bundle: true,
@@ -6,7 +6,45 @@ SPDX-License-Identifier: GPL-2.0-or-later
# Frontend source
This directory is the source of the browser assets embedded by
`trx-frontend-http`. Run `npm run build` from the parent `frontend` directory
after changing a source file. Cargo consumes the committed output under
`../assets/web/generated` and does not invoke Node.js.
This directory contains the strict TypeScript source for the browser assets
embedded by `trx-frontend-http`. `bootstrap.ts` is the production entry point;
esbuild follows its imports and emits `/app.js`. Feature plugins are ESM entry
points loaded on demand by `plugin-loader.ts`, and the decode-history worker is
built with its own Web Worker TypeScript configuration.
Run all frontend commands from the parent `frontend` directory:
```sh
npm ci
npm run typecheck
npm run lint
npm test
npm run test:browser
npm run build
npm run verify-generated
```
`npm run verify-generated` regenerates Rust wire contracts and browser bundles,
then rejects drift from the committed files. The browser smoke test needs a
local Chromium-family executable; set `CHROMIUM_PATH` when it is not installed
at a conventional system path.
Cargo consumes committed output under `../assets/web/generated`. It never
invokes Node.js, installs packages, or accesses the network. After changing a
source file, commit the deterministic generated asset changes together with the
source change.
## Boundaries
- `api/generated.ts` is generated from Rust and must not be edited manually.
- `api/client.ts` owns runtime validation at version-sensitive HTTP and SSE
boundaries.
- `core/` contains dependency-light shared helpers.
- `features/` contains application behavior grouped by responsibility.
- `plugins/` contains lazy decoder and high-coupling feature entries.
- Files under `assets/web/vendor/` are vendored JavaScript and are not part of
the TypeScript migration.
First-party features communicate through imports, the typed plugin runtime, or
the documented `window.trx` host interface. New standalone `window` callbacks
are not permitted.
@@ -46,6 +46,7 @@ import type { NumericBins } from "./features/spectrum/math.js";
import type { AuthRole } from "./api/auth.js";
import type { RdsData, RigCapabilities, RigListItem, RigSnapshot } from "./api/generated.js";
import type { TrxPluginRuntime } from "./plugins/runtime-contract.js";
import { loadEagerPlugins, loadPluginsForTab } from "./plugin-loader.js";
interface SpectrumFrame {
bins: NumericBins;
@@ -4530,7 +4531,7 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
updateTabHistory(name, replaceHistory);
}
scheduleSpectrumLayout();
if (typeof window.loadPluginsForTab === "function") window.loadPluginsForTab(name);
void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); });
if (name === "map") {
_initMapWhenReady();
}
@@ -4787,7 +4788,7 @@ window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules
// Load plugin scripts now that window.trx is populated. Dynamic scripts are
// async so they must not be created before the namespace they depend on exists.
if (typeof window.loadEagerPlugins === "function") window.loadEagerPlugins();
void loadEagerPlugins().catch((error: unknown) => { console.error(error); });
// Start the app
initializeApp();
@@ -7829,17 +7830,14 @@ window.addEventListener("keydown", (event) => {
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (shouldIgnoreGlobalShortcut(event.target)) return;
// S — spectrum screenshot (lazy-loads screenshot.js on first use)
// S — spectrum screenshot (lazy-loads its typed module on first use)
if (key === "s") {
event.preventDefault();
if (window.trx.modules.screenshot) {
void window.trx.modules.screenshot.captureSpectrumScreenshot();
} else {
const s = document.createElement("script");
s.src = "/screenshot.js";
s.onload = () => { void window.trx.modules.screenshot?.captureSpectrumScreenshot(); };
document.body.appendChild(s);
}
} else void import("./screenshot.js")
.then(() => window.trx.modules.screenshot?.captureSpectrumScreenshot())
.catch((error: unknown) => { console.error("Screenshot module failed to load", error); });
return;
}
@@ -2,4 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import "./app.ts";
import "./webgl-renderer.js";
import "./ui-core.js";
import "./plugin-runtime.js";
import "./leaflet-ais-tracksymbol.js";
import "./app.js";
@@ -36,21 +36,10 @@ async function loadPlugins(group: string): Promise<void> {
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); });
export async function loadEagerPlugins(): Promise<void> {
await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins));
}
const loaderWindow = window as typeof window & {
loadEagerPlugins?: () => Promise<void>;
loadPluginsForTab?: (tab: string) => Promise<void>;
};
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<HTMLElement>("[data-tab]")?.dataset.tab;
if (tab) requestPlugins(tab);
});
export async function loadPluginsForTab(tab: string): Promise<void> {
await loadPlugins(tab);
}
@@ -21,6 +21,7 @@ interface TrxScreenshotBridge {
showHint(message: string, durationMs: number): void;
modules: { screenshot?: ScreenshotModule };
}
export {};
const screenshotWindow = window as typeof window & { trx: TrxScreenshotBridge };
(function () {
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
test("AIS entry forwards positioned vessels with normalized metadata", async () => {
const forwarded = [];
@@ -23,7 +24,7 @@ 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 runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
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);
@@ -3,7 +3,6 @@
// 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";
import { bundleEntry } from "./bundle-entry.mjs";
@@ -26,7 +25,7 @@ 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 runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await bundleEntry(new URL("../src/plugins/aprs.ts", import.meta.url));
assert.equal(source.includes("raw.githubusercontent.com"), false);
new vm.Script(runtime).runInContext(context);
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
class ElementFixture {
constructor() { this.children = []; this.textContent = ""; this.style = {}; this.classList = { toggle() {} }; }
@@ -37,7 +38,7 @@ test("CW entry appends server-decoded text and registers lifecycle callbacks", a
Math,
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/cw.js", import.meta.url), "utf8");
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -3,7 +3,6 @@
// 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";
import { bundleEntry } from "./bundle-entry.mjs";
@@ -30,7 +29,7 @@ test("FT2 entry normalizes audio offsets and registers typed callbacks", async (
Array,
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await bundleEntry(new URL("../src/plugins/ft2.ts", import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -62,7 +61,7 @@ test("FT8 entry installs shared parsing without relying on script globals", asyn
Set,
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await bundleEntry(new URL("../src/plugins/ft8.ts", import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -3,7 +3,6 @@
// 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";
import { bundleEntry } from "./bundle-entry.mjs";
@@ -22,7 +21,7 @@ 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 runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await bundleEntry(new URL("../src/plugins/hf-aprs.ts", import.meta.url));
assert.equal(source.includes("raw.githubusercontent.com"), false);
new vm.Script(runtime).runInContext(context);
@@ -3,9 +3,9 @@
// 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";
import { bundleEntry } from "./bundle-entry.mjs";
test("local AIS track-symbol adapter renders immediately on first add", async () => {
const listeners = new Map();
@@ -34,10 +34,7 @@ test("local AIS track-symbol adapter renders immediately on first add", async ()
Util: { extend: Object.assign },
divIcon: (options) => options,
};
const source = await readFile(
new URL("../../assets/web/generated/leaflet-ais-tracksymbol.js", import.meta.url),
"utf8",
);
const source = await bundleEntry(new URL("../src/leaflet-ais-tracksymbol.ts", import.meta.url));
new vm.Script(source).runInNewContext({ L, console });
Object.assign(L.TrxAisTrackSymbol.prototype, {
onAdd: markerPrototype.onAdd,
@@ -3,14 +3,14 @@
// 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";
import { bundleEntry } from "./bundle-entry.mjs";
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");
const source = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
new vm.Script(source).runInContext(context);
assert.equal(window.trxPluginRuntime.dispatch("late", { sequence: 1 }), false);
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
test("satellite entry registers lifecycle callbacks and forwards georeferenced images", async () => {
const overlays = [];
@@ -26,7 +27,7 @@ test("satellite entry registers lifecycle callbacks and forwards georeferenced i
Error,
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/sat.js", import.meta.url), "utf8");
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -10,10 +10,11 @@ const indexPath = new URL("../../assets/web/index.html", import.meta.url);
const pluginLoaderPath = new URL("../src/plugin-loader.ts", import.meta.url);
const mapCorePath = new URL("../src/map-core.ts", import.meta.url);
test("index loads the shared UI before the application", async () => {
test("index loads one first-party application bootstrap", 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.match(/<script[^>]+src="\/(?!vendor\/)[^"]+\.js"/g) ?? []).length, 1);
assert.match(html, /src="\/app\.js"/);
assert.doesNotMatch(html, /src="\/(?:ui-core|plugin-runtime|plugin-loader|webgl-renderer|leaflet-ais-tracksymbol)\.js"/);
assert.equal(html.includes("var pluginScripts"), false);
});
@@ -3,8 +3,8 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
class ClassList {
constructor() { this.values = new Set(); }
@@ -104,7 +104,7 @@ const context = vm.createContext({
console,
});
const source = await readFile(new URL("../../assets/web/generated/ui-core.js", import.meta.url), "utf8");
const source = await bundleEntry(new URL("../src/ui-core.ts", import.meta.url));
new vm.Script(source, { filename: "ui-core.js" }).runInContext(context);
const ui = window.trxUi;
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
test("VDES entry normalizes and forwards positioned server messages", async () => {
const forwarded = [];
@@ -22,7 +23,7 @@ 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 runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
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);
@@ -3,9 +3,9 @@
// 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";
import { bundleEntry } from "./bundle-entry.mjs";
test("WebGL color helpers normalize CSS and HSL colors", async () => {
const window = {};
@@ -22,10 +22,7 @@ test("WebGL color helpers normalize CSS and HSL colors", async () => {
String,
parseInt,
});
const source = await readFile(
new URL("../../assets/web/generated/webgl-renderer.js", import.meta.url),
"utf8",
);
const source = await bundleEntry(new URL("../src/webgl-renderer.ts", import.meta.url));
new vm.Script(source).runInContext(context);
assert.deepEqual(Array.from(window.trxParseCssColor("#ff800080")), [1, 128 / 255, 0, 128 / 255]);
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", async () => {
const status = { textContent: "", style: { color: "" } };
@@ -19,7 +20,7 @@ test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", a
Uint8Array,
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/wefax.js", import.meta.url), "utf8");
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
test("WSPR entry forwards decoded locators at their absolute frequency", async () => {
const forwarded = [];
@@ -25,7 +26,7 @@ test("WSPR entry forwards decoded locators at their absolute frequency", async (
Element: class Element {},
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/wspr.js", import.meta.url), "utf8");
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);