Files
trx-rs/docs/ts-migration-plan.md
sjg 695434942f
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 2s
CI / frontend (pull_request) Failing after 42s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 1s
CI / frontend (push) Failing after 44s
CI / reuse (push) Failing after 2s
chore: complete TypeScript migration cleanup
2026-08-01 22:39:37 +02:00

19 KiB

TypeScript Migration Plan

Scope: src/trx-client/trx-frontend/trx-frontend-http/

Status: Complete (2026-08-01)

Implementation result

The migration was completed on feat/typescript-frontend-migration. The baseline and phased sections below are retained as the decision record; their descriptions of JavaScript files and classic loading refer to the pre-migration state.

Completion evidence:

  • all first-party browser sources are strict .ts files, checked by separate DOM and Web Worker TypeScript projects with no JavaScript compatibility mode or suppression directives;
  • bootstrap.ts is the single first-party HTML entry and esbuild represents startup order, lazy feature imports, shared hashed chunks, and the worker in its module graph;
  • obsolete source and generated compatibility JavaScript was removed;
  • Rust generates rig, status, capability, decoder, and flattened frontend metadata contracts into api/generated.ts; runtime guards validate HTTP, SSE, WebSocket, and worker boundaries;
  • the generic embedded-asset handler serves a build-generated allowlist with constrained MIME types, compression, ETags, immutable caching, and no file system lookup;
  • intentional browser host namespaces and transitional lazy-feature properties are documented in docs/frontend-architecture.md;
  • CI uses locked npm dependencies, caches npm downloads rather than node_modules, runs strict type checking and linting, unit/DOM/worker tests, Chromium startup coverage, generated-output drift checks, and REUSE after generation;
  • Cargo continues to consume committed generated assets without invoking Node or requiring network access.

The final local gate ran npm ci, type checking, linting, 30 frontend tests, the Chromium smoke flow (startup, auth gate, audio controls, rig switching, map initialization, and navigation), generated-contract and bundle verification, workspace formatting, Clippy with warnings denied, all-target builds, workspace tests, and REUSE 3.3 validation.

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. Baseline 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:

  1. Create checked contracts between Rust responses and browser code.
  2. Replace global callbacks with explicit module interfaces.
  3. Split app.js by responsibility.
  4. Make rig, capability, decoder, scheduler, audio, and spectrum state explicit.
  5. Preserve lazy loading for expensive features.
  6. Add frontend type checking, linting, and automated tests to CI.
  7. Keep ordinary Cargo builds independent of Node.js and network access.
  8. 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.rs install packages or download frontend dependencies;
  • convert all of app.js in 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:

  1. TypeScript and JavaScript sources live under frontend/src.
  2. esbuild writes browser-ready output under assets/web/generated.
  3. Generated browser output is committed to the repository.
  4. Rust embeds generated output, not TypeScript source.
  5. 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, and RigCapabilities;
  • the /rigs response;
  • 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:

  1. WebGL renderer;
  2. decode-history worker;
  3. screenshot support;
  4. FT2 and FT4;
  5. WSPR;
  6. 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:

  1. authentication and API transport;
  2. rig enumeration and switching;
  3. radio commands and capabilities;
  4. audio streaming;
  5. spectrum state and rendering;
  6. decode history;
  7. recorder;
  8. keyboard shortcuts;
  9. 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:

  1. Pure unit tests for bandwidth calculations, formatting, state changes, capability decisions, and message routing.
  2. DOM component tests for dialogs, tabs, workspace selection, rig switching, and collapsible control sections.
  3. Worker tests for decode-history pruning, batching, and message formats.
  4. 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:

  1. tooling and unchanged JavaScript build;
  2. generated Rust wire types and typed API client;
  3. ui-core conversion;
  4. worker and WebGL conversion;
  5. decoder plugin conversions in small groups;
  6. plugin registry and dynamic imports;
  7. one app.js responsibility per PR;
  8. scheduler, bookmarks, and map conversions;
  9. generic embedded asset serving;
  10. 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 window globals;
  • 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:

  1. adding the frontend package and locked toolchain;
  2. building existing JavaScript to fixed-name generated output;
  3. adding frontend CI and drift checks;
  4. generating initial rig/status TypeScript definitions;
  5. introducing the typed API client;
  6. converting ui-core.js to strict TypeScript;
  7. 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 window callbacks remain;
  • app.js has 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.