docs: add TypeScript migration plan
This commit is contained in:
@@ -0,0 +1,549 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# 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:
|
||||
|
||||
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
|
||||
|
||||
```text
|
||||
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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```text
|
||||
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:
|
||||
|
||||
```text
|
||||
/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`:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```sh
|
||||
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.
|
||||
Reference in New Issue
Block a user