17 KiB
TypeScript Migration Plan
Scope:
src/trx-client/trx-frontend/trx-frontend-http/Status: Proposed
1. Decision
Migrating the web frontend to TypeScript is worthwhile, provided it is used to
remove implicit contracts and global coupling. Renaming JavaScript files to
.ts without changing their boundaries would add tooling without delivering
the main safety benefits.
The migration must be incremental. Every intermediate commit and pull request must leave the frontend buildable and usable.
2. Current State
The frontend currently contains roughly 21,700 lines of first-party JavaScript. Its largest components include:
app.js: approximately 8,900 lines;map-core.js: approximately 3,500 lines;plugins/scheduler.js: approximately 1,500 lines;plugins/bookmarks.js: approximately 800 lines;plugins/vchan.js: approximately 560 lines.
Important characteristics of the current architecture are:
- scripts are embedded individually into the Rust binary with
include_str!; - the Rust HTTP server exposes an explicit route for each asset;
- plugins are loaded dynamically as classic scripts;
- script order is significant;
- modules communicate through
window.*,window.trx, callbacks, and shared mutable state; - the main HTML file contains the plugin loader;
- a Web Worker is loaded through a fixed URL;
- Cargo builds do not require Node.js;
- CI images contain Node.js, but CI currently runs only Rust and REUSE checks;
- Leaflet, Opus decoder, fonts, images, and other vendored assets are local.
These constraints make a big-bang rewrite unnecessarily risky.
3. Goals
The migration should:
- Create checked contracts between Rust responses and browser code.
- Replace global callbacks with explicit module interfaces.
- Split
app.jsby responsibility. - Make rig, capability, decoder, scheduler, audio, and spectrum state explicit.
- Preserve lazy loading for expensive features.
- Add frontend type checking, linting, and automated tests to CI.
- Keep ordinary Cargo builds independent of Node.js and network access.
- Preserve existing browser behavior throughout the migration.
4. Non-Goals
The migration will not initially:
- introduce a UI framework;
- redesign the user interface;
- convert vendored JavaScript to TypeScript;
- change REST, SSE, WebSocket, or worker protocols unless required to make an existing contract unambiguous;
- make
build.rsinstall packages or download frontend dependencies; - convert all of
app.jsin one pull request.
5. Target Layout
trx-frontend-http/
├── frontend/
│ ├── package.json
│ ├── package-lock.json
│ ├── tsconfig.json
│ ├── tsconfig.worker.json
│ ├── build.mjs
│ ├── src/
│ │ ├── bootstrap.ts
│ │ ├── api/
│ │ │ ├── client.ts
│ │ │ └── generated.ts
│ │ ├── core/
│ │ │ ├── dom.ts
│ │ │ ├── events.ts
│ │ │ ├── settings.ts
│ │ │ └── state.ts
│ │ ├── features/
│ │ │ ├── audio/
│ │ │ ├── bookmarks/
│ │ │ ├── map/
│ │ │ ├── navigation/
│ │ │ ├── radio/
│ │ │ ├── recorder/
│ │ │ ├── scheduler/
│ │ │ └── spectrum/
│ │ ├── decoders/
│ │ ├── workers/
│ │ └── legacy/
│ │ └── global-bridge.ts
│ └── tests/
└── assets/web/
├── generated/
├── vendor/
├── index.html
├── style.css
└── themes.css
The exact feature directories may evolve, but dependencies should point from
features toward core and api, never from core back into features.
6. Tooling
Use TypeScript for type checking and esbuild for bundling. A framework-specific development server is not needed.
Recommended compiler baseline:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"useUnknownInCatchVariables": true,
"noEmit": true,
"lib": ["ES2022", "DOM"]
}
}
Worker code should use a separate configuration with WebWorker rather than
DOM globals.
The package scripts should provide at least:
npm run typecheck
npm run lint
npm test
npm run build
npm run verify-generated
Pin dependencies in package-lock.json. New package and generated files must
carry or inherit valid REUSE licensing information.
7. Cargo and Asset Integration
Cargo must remain usable on machines without Node.js. Do not invoke npm
automatically from build.rs.
The initial integration should work as follows:
- TypeScript and JavaScript sources live under
frontend/src. - esbuild writes browser-ready output under
assets/web/generated. - Generated browser output is committed to the repository.
- Rust embeds generated output, not TypeScript source.
- CI rebuilds the frontend and fails if committed output is stale.
During early phases, retain the existing public URLs, including:
/app.js
/ui-core.js
/map-core.js
/scheduler.js
/decode-history-worker.js
Keeping these URLs stable avoids coupling the first migration phases to changes in HTML loading, authentication rules, caching, or Rust routing.
After modules and code splitting are established, replace the explicit list of Rust constants and handlers with an embedded generated-asset directory. The server must still enforce an allowlist, correct MIME types, cache headers, and path traversal protection.
8. Rust-to-TypeScript Contracts
Generate TypeScript definitions from Rust wire types instead of maintaining parallel handwritten representations.
Initial candidates include:
RigState,RigInfo, andRigCapabilities;- the
/rigsresponse; - decoder registry entries;
- scheduler configuration and status;
- filter and spectrum state;
- recorder responses;
- SSE update payloads;
- decoded-message variants;
- WebSocket control and status messages.
A generator such as ts-rs can produce
frontend/src/api/generated.ts. Generated definitions should describe only
wire formats. UI state and view models should remain handwritten.
CI must regenerate these definitions and fail when the working tree changes. Serde renames, tagged enums, optional fields, flattened values, and numeric ranges must be checked explicitly during the initial generator integration.
TypeScript types do not validate data at runtime. Critical compatibility boundaries should retain focused runtime checks for missing or malformed data, particularly when clients and servers may run different versions.
9. Target Module Contracts
Plugins should eventually implement an explicit interface rather than assigning
callbacks to window:
interface TrxPlugin {
readonly id: string;
initialize(context: PluginContext): void | Promise<void>;
dispose?(): void;
}
interface PluginContext {
api: TrxApi;
events: TrxEventBus;
navigation: NavigationService;
notifications: NotificationService;
state: ReadonlyRadioState;
}
During the transition, legacy/global-bridge.ts may expose the minimum globals
required by unmigrated scripts. The bridge must shrink as migration progresses;
new feature code must not add new global callbacks.
State should be divided by responsibility rather than replaced by one large typed global:
interface RadioState {
activeRigId: string | null;
capabilities: RigCapabilities | null;
connection: ConnectionState;
frequencyHz: number | null;
mode: RigMode | null;
bandwidthHz: number | null;
}
interface AudioState {
rx: AudioStreamState;
tx: AudioStreamState;
volume: VolumeState;
}
interface DecoderState {
registry: DecoderDescriptor[];
status: ReadonlyMap<DecoderId, DecoderStatus>;
}
Commands should receive a rig ID explicitly. They must not infer their target from mutable global selection state after an asynchronous operation begins.
10. Migration Phases
Phase 0: Baseline and Measurements
- Record current asset names, sizes, and load order.
- Add a browser startup smoke test.
- Record existing global symbols and plugin callbacks.
- Confirm that generated bundles do not introduce remote runtime dependencies.
Exit criterion: current behavior and asset loading have an automated baseline.
Phase 1: Tooling Scaffold
- Add the frontend package, lockfile, TypeScript configuration, and esbuild.
- Allow existing JavaScript as build input without type checking it globally.
- Produce fixed-name outputs matching current URLs.
- Add frontend commands and CI checks.
- Document local frontend development commands.
Exit criterion: existing JavaScript passes through the frontend build with no runtime changes, and CI detects stale generated output.
Phase 2: Generated API Types
- Add Rust-to-TypeScript type generation.
- Generate the first status, rig, capability, and decoder contracts.
- Introduce a typed fetch/post client and typed SSE decoding boundary.
- Keep narrow runtime guards at compatibility boundaries.
Exit criterion: new API consumers cannot use untyped response objects.
Phase 3: Core Browser Services
Convert or create:
- DOM lookup helpers;
- notifications and confirmations;
- settings and per-rig preferences;
- navigation;
- the event bus;
- application state stores;
- plugin registry and loader.
Convert ui-core.js as the first strict TypeScript entry.
Exit criterion: shared UI behavior is TypeScript, tested, and does not add new globals.
Phase 4: Independent Leaf Modules
Convert lower-coupling code first:
- WebGL renderer;
- decode-history worker;
- screenshot support;
- FT2 and FT4;
- WSPR;
- CW and other decoder views.
Workers must be separate build entries.
Exit criterion: each converted module has typed inputs, outputs, and tests.
Phase 5: Plugin Loading
- Move the inline loader out of
index.html. - Replace classic-script injection with typed dynamic imports.
- Register plugins through
TrxPlugin. - Preserve lazy loading by tab and feature.
- Remove the corresponding global callbacks after each plugin migrates.
Exit criterion: plugin dependencies and loading order are represented by the module graph rather than implicit script order.
Phase 6: Split the Main Application
Extract app.js by responsibility:
- authentication and API transport;
- rig enumeration and switching;
- radio commands and capabilities;
- audio streaming;
- spectrum state and rendering;
- decode history;
- recorder;
- keyboard shortcuts;
- application bootstrap.
Do not split by arbitrary line ranges. Each extraction must establish an explicit interface and remove the corresponding globals.
Exit criterion: the bootstrap file composes services and features but does not contain their implementations.
Phase 7: High-Coupling Features
Convert the remaining large features after the core contracts are stable:
- scheduler and satellite scheduler;
- bookmarks;
- virtual channels;
- map and map-backed statistics;
- remaining decoder plugins.
Exit criterion: no first-party classic scripts or untyped plugin callbacks remain.
Phase 8: Asset-Server Consolidation
- Enable code splitting and hashed chunks.
- Embed the generated asset directory in Rust.
- Serve generated assets through a generic, allowlisted handler.
- Add appropriate immutable caching for hashed output.
- Retain stable handling for HTML and version metadata.
- Remove obsolete fixed-asset constants and handlers.
Exit criterion: Rust no longer needs a source edit for every generated frontend chunk.
Phase 9: Strictness and Cleanup
- Remove
allowJs. - Remove the legacy global bridge.
- Enable all selected strict compiler and lint rules.
- Remove obsolete generated compatibility bundles.
- Update architecture and contributor documentation.
Exit criterion: all first-party frontend source is strict TypeScript and the browser runtime exposes only intentionally documented globals.
11. Testing Strategy
Frontend CI should run:
npm ci
npm run typecheck
npm run lint
npm test
npm run build
git diff --exit-code -- assets/web/generated frontend/src/api/generated.ts
Testing should cover four layers:
- Pure unit tests for bandwidth calculations, formatting, state changes, capability decisions, and message routing.
- DOM component tests for dialogs, tabs, workspace selection, rig switching, and collapsible control sections.
- Worker tests for decode-history pruning, batching, and message formats.
- Browser smoke tests for startup, authentication gating, plugin loading, navigation, rig switching, audio controls, and map initialization.
The existing dependency-free ui-core test can remain during the scaffold
phase. It should be moved to the standard TypeScript test runner once that
runner is established.
12. CI Changes
Add a dedicated frontend job rather than hiding frontend work inside the Cargo jobs. The job should:
- use the Node.js version provided by the runner image;
- run with
npm ci, never a mutable install; - cache the npm download cache, not
node_modules; - type-check, lint, test, and build;
- verify generated artifacts and Rust-generated types are current;
- run REUSE validation after generated files are produced.
Rust lint and test jobs should continue to consume committed generated assets.
13. Pull Request Strategy
Use small, independently reversible pull requests. A recommended sequence is:
- tooling and unchanged JavaScript build;
- generated Rust wire types and typed API client;
ui-coreconversion;- worker and WebGL conversion;
- decoder plugin conversions in small groups;
- plugin registry and dynamic imports;
- one
app.jsresponsibility per PR; - scheduler, bookmarks, and map conversions;
- generic embedded asset serving;
- removal of JavaScript compatibility mode.
Every PR should include:
- runtime behavior preserved or intentionally documented;
- frontend type checking and tests;
- strict Rust formatting and Clippy;
- generated-artifact drift verification;
- no newly introduced undocumented
windowglobals; - no remote runtime asset dependency.
14. Risks and Mitigations
Superficial Conversion
Risk: globals receive declarations but remain coupled and mutable.
Mitigation: require every converted module to expose explicit inputs and outputs and remove at least the globals it replaces.
Cargo Becomes Dependent on Node
Risk: builds fail on systems without Node or network access.
Mitigation: commit deterministic generated output and keep npm outside
build.rs.
Script-Order Regressions
Risk: switching to modules changes execution timing and scope.
Mitigation: retain current public entries initially, then replace script loading only after the plugin registry is in place.
Unreviewable app.js Rewrite
Risk: a large conversion is difficult to review, test, or bisect.
Mitigation: extract one responsibility at a time and keep bootstrap working after every extraction.
Rust and Browser Contracts Drift
Risk: TypeScript compiles against stale wire definitions.
Mitigation: generate types from Rust and enforce a clean working tree after generation in CI.
Generated Asset Noise
Risk: committed bundles make reviews noisy.
Mitigation: separate source and generated commits when useful, include source maps, and require deterministic output.
Bundle or Startup Regression
Risk: bundling loads too much code eagerly.
Mitigation: record the current baseline, preserve feature-level lazy loads, and track entry/chunk sizes in CI.
15. Initial Proof of Concept
The first implementation PR should be deliberately limited to:
- adding the frontend package and locked toolchain;
- building existing JavaScript to fixed-name generated output;
- adding frontend CI and drift checks;
- generating initial rig/status TypeScript definitions;
- introducing the typed API client;
- converting
ui-core.jsto strict TypeScript; - preserving every existing asset URL and observable behavior.
This proof of concept is the decision gate for the remainder of the migration. If it materially improves contract safety without making Cargo development unwieldy, continue with the phased plan. If it does not, the repository can retain the tooling and the converted core without committing to a full rewrite.
16. Completion Criteria
The migration is complete when:
- all first-party browser source is strict TypeScript;
- vendor files remain isolated and declared through narrow type adapters;
- Rust wire types generate their browser contracts;
- no feature depends on accidental script ordering;
- no undocumented mutable
windowcallbacks remain; app.jshas been replaced by a small typed bootstrap and feature modules;- frontend type checking, linting, unit tests, component tests, browser smoke tests, and generated-output checks run in CI;
- Cargo builds remain possible without Node.js or network access;
- production assets remain local, embedded, cacheable, and reproducible.