feat(web-replay): add RDP session replay web component stack#1216
feat(web-replay): add RDP session replay web component stack#1216Karthik S Kashyap (karthikkashyap98) wants to merge 3 commits intoDevolutions:masterfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a full “web replay” stack to IronRDP: a new Rust→WASM replay engine, a Svelte custom-element player UI that consumes a ReplayDataSource, and a SvelteKit demo app showing HTTP Range + local-file playback. It also integrates new cargo xtask web *-replay commands to build/check/run the replay packages.
Changes:
- Add
ironrdp-web-replay(Rust/WASM) replay engine +ironrdp-testsuite-replayintegration tests. - Add
<iron-replay-player>Svelte 5 custom element (store, UI, unit + browser tests). - Add
iron-svelte-replay-clientdemo app + extendxtask webwith replay build/install/check/run commands.
Reviewed changes
Copilot reviewed 85 out of 93 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| xtask/src/web.rs | Adds replay build/install/check/run tasks and a shared wasm-pack JS patcher for Vite. |
| xtask/src/main.rs | Wires new replay web actions into the xtask dispatcher. |
| xtask/src/cli.rs | Adds CLI help + argument parsing for web *-replay commands. |
| web-client/iron-svelte-replay-client/vite.config.ts | New demo app Vite config (SvelteKit + WASM/top-level-await plugins). |
| web-client/iron-svelte-replay-client/tsconfig.json | New demo app TS config. |
| web-client/iron-svelte-replay-client/svelte.config.js | New demo app SvelteKit adapter config. |
| web-client/iron-svelte-replay-client/static/favicon.svg | Demo app favicon asset. |
| web-client/iron-svelte-replay-client/src/routes/http/+page.svelte | HTTP Range demo route wiring <iron-replay-player> with HttpRangeDataSource. |
| web-client/iron-svelte-replay-client/src/routes/file/+page.svelte | Local file demo route wiring <iron-replay-player> with LocalFileDataSource. |
| web-client/iron-svelte-replay-client/src/routes/+page.svelte | Demo home page (source selection). |
| web-client/iron-svelte-replay-client/src/routes/+layout.svelte | Demo layout with simple nav/back link. |
| web-client/iron-svelte-replay-client/src/lib/recordingFormat.ts | Recording format constants + searchByTime utility. |
| web-client/iron-svelte-replay-client/src/lib/initPlayer.ts | Demo helper to init WASM backend and set element properties. |
| web-client/iron-svelte-replay-client/src/lib/fetchRecording.ts | HTTP Range fetch + header/index parsing utilities. |
| web-client/iron-svelte-replay-client/src/lib/ReplayDataSource.types.ts | Demo-local structural typing for ReplayDataSource compatibility. |
| web-client/iron-svelte-replay-client/src/lib/LocalFileDataSource.ts | Demo local-file ReplayDataSource implementation. |
| web-client/iron-svelte-replay-client/src/lib/HttpRangeDataSource.ts | Demo HTTP Range ReplayDataSource implementation. |
| web-client/iron-svelte-replay-client/src/app.html | Loads the built <iron-replay-player> bundle into the demo app. |
| web-client/iron-svelte-replay-client/src/app.d.ts | SvelteKit app type stub. |
| web-client/iron-svelte-replay-client/scripts/replay-server.mjs | Dev-only Range-enabled HTTP server for sample recordings. |
| web-client/iron-svelte-replay-client/pre-build.js | Pre-build orchestrator to build/copy component + WASM adapter artifacts into static/. |
| web-client/iron-svelte-replay-client/package.json | Demo app scripts and dev tooling dependencies. |
| web-client/iron-svelte-replay-client/eslint.config.mjs | Demo app ESLint flat config. |
| web-client/iron-svelte-replay-client/README.md | Demo app usage/docs and recording file format spec. |
| web-client/iron-svelte-replay-client/.prettierrc.yaml | Demo app Prettier config. |
| web-client/iron-svelte-replay-client/.prettierignore | Demo app Prettier ignore list. |
| web-client/iron-svelte-replay-client/.npmrc | Demo app npm engine strict setting. |
| web-client/iron-svelte-replay-client/.gitignore | Demo app gitignore (build + generated static artifacts). |
| web-client/iron-replay-player/vitest.config.ts | Unit-test Vitest config for the replay player package. |
| web-client/iron-replay-player/vitest.browser.config.ts | Browser-test Vitest config (Playwright/Chromium). |
| web-client/iron-replay-player/vite.config.ts | Library build config for the <iron-replay-player> bundle. |
| web-client/iron-replay-player/tsconfig.node.json | TS project reference for Vite config typing. |
| web-client/iron-replay-player/tsconfig.json | Replay player TS config (Svelte + tests). |
| web-client/iron-replay-player/tests/helpers/mock-wasm-replay.ts | WASM backend mock for unit/browser tests. |
| web-client/iron-replay-player/tests/helpers/mock-data-source.ts | Deferred-promise mock ReplayDataSource for tests. |
| web-client/iron-replay-player/tests/format-time.test.ts | Unit tests for formatTime(). |
| web-client/iron-replay-player/tests/browser/speed-selector.browser.test.ts | Browser tests for speed selector behavior. |
| web-client/iron-replay-player/tests/browser/setup.ts | Browser test mount helpers + ready-event capture. |
| web-client/iron-replay-player/tests/browser/seek.browser.test.ts | Browser tests for seekbar pointer + keyboard seek behavior. |
| web-client/iron-replay-player/tests/browser/playback-controls.browser.test.ts | Browser tests for play/pause/reset/overlay interactions. |
| web-client/iron-replay-player/tests/browser/overlays.browser.test.ts | Browser tests for loading/buffering/action/ended overlays. |
| web-client/iron-replay-player/tests/README.md | Test strategy + patterns documentation. |
| web-client/iron-replay-player/svelte.config.js | Enables Svelte custom element compilation. |
| web-client/iron-replay-player/src/ui/format-time.ts | Time formatting utility used by UI. |
| web-client/iron-replay-player/src/ui/SeekBar.svelte | Seekbar UI component (pointer-driven slider). |
| web-client/iron-replay-player/src/ui/PlaybackControls.svelte | Playback controls UI (play/pause/reset/speed/fullscreen). |
| web-client/iron-replay-player/src/services/replay-store.svelte.ts | Core store/state machine (buffering, seek, rAF loop, errors). |
| web-client/iron-replay-player/src/main.ts | Library entry point exporting CE + public TS types. |
| web-client/iron-replay-player/src/iron-replay-player.svelte | <iron-replay-player> custom element implementation + styling. |
| web-client/iron-replay-player/src/interfaces/ReplayModule.ts | TS interface for injected WASM backend module + instance API. |
| web-client/iron-replay-player/src/interfaces/ReplayDataSource.ts | TS interface for consumer-provided replay data sources + errors. |
| web-client/iron-replay-player/src/interfaces/PlayerApi.ts | Public imperative API shape emitted via ready event. |
| web-client/iron-replay-player/src/interfaces/PlaybackState.ts | Store playback state type. |
| web-client/iron-replay-player/src/interfaces/LoadState.ts | Store load state type. |
| web-client/iron-replay-player/package.json | Replay player package scripts + dev deps. |
| web-client/iron-replay-player/eslint.config.mjs | Replay player ESLint flat config. |
| web-client/iron-replay-player/README.md | Replay player usage docs (module injection, datasource contract, API). |
| web-client/iron-replay-player/.prettierrc.yaml | Replay player Prettier config. |
| web-client/iron-replay-player/.prettierignore | Replay player Prettier ignore list. |
| web-client/iron-replay-player/.npmrc | Replay player npm engine strict setting. |
| web-client/iron-replay-player/.gitignore | Replay player gitignore. |
| web-client/iron-replay-player-wasm/vite.config.ts | Adapter library build config (ES module + dts generation). |
| web-client/iron-replay-player-wasm/tsconfig.node.json | TS project reference for Vite config typing. |
| web-client/iron-replay-player-wasm/tsconfig.json | WASM adapter TS config. |
| web-client/iron-replay-player-wasm/src/main.ts | Adapter module exporting init() + ReplayBackend. |
| web-client/iron-replay-player-wasm/pre-build.js | Adapter prebuild script invoking cargo xtask web build-replay. |
| web-client/iron-replay-player-wasm/package.json | WASM adapter scripts + dev deps. |
| web-client/iron-replay-player-wasm/eslint.config.mjs | WASM adapter ESLint flat config. |
| web-client/iron-replay-player-wasm/.prettierrc.yaml | WASM adapter Prettier config. |
| web-client/iron-replay-player-wasm/.prettierignore | WASM adapter Prettier ignore list. |
| web-client/iron-replay-player-wasm/.npmrc | WASM adapter npm engine strict setting. |
| web-client/iron-replay-player-wasm/.gitignore | WASM adapter gitignore. |
| crates/ironrdp-web-replay/src/replay.rs | WASM-facing Replay engine (canvas blit + cursor compositing + config). |
| crates/ironrdp-web-replay/src/process.rs | Replay PDU routing/processing (FastPath/X224, seek suppression, results). |
| crates/ironrdp-web-replay/src/lib.rs | Crate module wiring + exported WASM-facing types. |
| crates/ironrdp-web-replay/src/buffer.rs | Timestamped FIFO PDU buffer for replay processing. |
| crates/ironrdp-web-replay/README.md | Crate documentation (API, build, tests, limitations). |
| crates/ironrdp-web-replay/Cargo.toml | New crate manifest + wasm/web dependencies. |
| crates/ironrdp-web-replay/.gitignore | Ignores wasm-pack outputs and targets. |
| crates/ironrdp-testsuite-replay/tests/web_replay/process.rs | Integration tests for replay processing/seek behavior/results. |
| crates/ironrdp-testsuite-replay/tests/web_replay/mod.rs | PDU buffer integration tests. |
| crates/ironrdp-testsuite-replay/tests/main.rs | Test harness entry for replay testsuite. |
| crates/ironrdp-testsuite-replay/test_data/pdu/web_replay/x224_server_demand_active.bin | Captured binary PDU fixture used by tests. |
| crates/ironrdp-testsuite-replay/src/lib.rs | Testsuite crate root. |
| crates/ironrdp-testsuite-replay/Cargo.toml | New replay testsuite crate manifest. |
| Cargo.lock | Adds lock entries for new crates and dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const firstEntry = this.indexTable[startIdx]; | ||
| const lastEntry = this.indexTable[endIdx - 1]; | ||
| // Safe: RDP recording byte offsets are well under Number.MAX_SAFE_INTEGER. | ||
| const startByte = Number(firstEntry.byteOffset); | ||
| const endByte = Number(lastEntry.byteOffset) + lastEntry.pduLength - 1; | ||
|
|
There was a problem hiding this comment.
This converts uint64 byte offsets to number without validating they’re within Number.MAX_SAFE_INTEGER. If a recording’s offsets exceed that, the computed Range request boundaries and subarray slicing will be incorrect. Consider keeping offsets as bigint (and computing ranges with bigint) or assert safe-integer bounds and fail fast with a clear error.
| // Sync cursor state from processor (clone to release the borrow). | ||
| let pointer_state = { | ||
| #[expect(clippy::unwrap_used, reason = "processor is Some per is_none() early return")] | ||
| self.processor.as_ref().unwrap().current_pointer_state().clone() | ||
| }; | ||
| match &pointer_state { | ||
| crate::process::PointerState::Bitmap(pointer) => { | ||
| self.pointer_hotspot_x = pointer.hotspot_x; | ||
| self.pointer_hotspot_y = pointer.hotspot_y; | ||
| self.cursor_canvas = Self::build_cursor_canvas(pointer); | ||
| self.pointer_hidden = false; | ||
| } |
There was a problem hiding this comment.
Cursor bitmap compositing rebuilds an OffscreenCanvas on every render_till() call whenever the pointer state is Bitmap, even if the pointer hasn’t changed. This is likely a large per-frame allocation/CPU cost. Consider caching the last DecodedPointer identity (e.g., store the Arc<DecodedPointer> and use Arc::ptr_eq to detect changes) and only rebuilding cursor_canvas when a new pointer bitmap update is observed.
| `ironrdp-testsuite-core`, following the same pattern as `ironrdp-session`. | ||
|
|
||
| ```sh | ||
| # Run all web-replay tests | ||
| cargo test -p ironrdp-testsuite-core -- web_replay | ||
|
|
||
| # Run a specific test | ||
| cargo test -p ironrdp-testsuite-core -- web_replay::pdu_buffer_clear_empties_buffer |
There was a problem hiding this comment.
README references ironrdp-testsuite-core and shows cargo test -p ironrdp-testsuite-core -- web_replay ..., but the tests added in this PR live under the new ironrdp-testsuite-replay crate. Update the crate name and example commands so they match the actual test location.
| `ironrdp-testsuite-core`, following the same pattern as `ironrdp-session`. | |
| ```sh | |
| # Run all web-replay tests | |
| cargo test -p ironrdp-testsuite-core -- web_replay | |
| # Run a specific test | |
| cargo test -p ironrdp-testsuite-core -- web_replay::pdu_buffer_clear_empties_buffer | |
| `ironrdp-testsuite-replay`, following the same pattern as `ironrdp-session`. | |
| ```sh | |
| # Run all web-replay tests | |
| cargo test -p ironrdp-testsuite-replay -- web_replay | |
| # Run a specific test | |
| cargo test -p ironrdp-testsuite-replay -- web_replay::pdu_buffer_clear_empties_buffer |
| "check": "svelte-check --tsconfig ./tsconfig.json", | ||
| "check:dist": "tsc ./dist/index.d.ts --noEmit", | ||
| "test": "vitest run", |
There was a problem hiding this comment.
check:dist runs tsc ./dist/index.d.ts --noEmit, but this package’s Vite config explicitly omits type generation (no dts plugin), so dist/index.d.ts won’t exist after vite build. Either remove/rename this script, or add a type-generation step that actually produces dist/index.d.ts.
| fn patch_vite_wasm_url(js_path: &std::path::Path, wasm_filename: &str) -> anyhow::Result<()> { | ||
| let content = fs::read_to_string(js_path)?; | ||
| let content = format!("import wasmUrl from './{wasm_filename}?url';\n\n{content}"); | ||
| let content = content.replace(&format!("new URL('{wasm_filename}', import.meta.url)"), "wasmUrl"); | ||
| fs::write(js_path, content)?; | ||
| Ok(()) |
There was a problem hiding this comment.
patch_vite_wasm_url() is not idempotent: every invocation prepends a new import wasmUrl ... line, so running cargo xtask web build* twice will produce duplicate imports and a JS parse error. Consider detecting an existing patch (e.g., check for the import line / a marker comment) before prepending, or rewrite the file in a way that’s safe to apply multiple times.
| const indexStart = HEADER_SIZE; | ||
| const indexView = new DataView(this.buffer, indexStart, INDEX_ROW_SIZE * this.totalPdus); | ||
| this.indexTable = []; |
There was a problem hiding this comment.
new DataView(this.buffer, indexStart, INDEX_ROW_SIZE * this.totalPdus) will throw a RangeError if the file is truncated (too small for the declared index table). Consider adding an explicit length check (buffer.byteLength >= HEADER_SIZE + INDEX_ROW_SIZE*totalPdus) and throwing a clearer error message before constructing the DataView.
| const pduLength = indexView.getUint32(offset + 4, false); | ||
| const byteOffset = Number(indexView.getBigUint64(offset + 8, false)); | ||
| if (byteOffset + pduLength > this.buffer.byteLength) { | ||
| throw new Error(`PDU ${i} extends beyond file boundary (offset ${byteOffset}, length ${pduLength}, file size ${this.buffer.byteLength})`); | ||
| } |
There was a problem hiding this comment.
byteOffset is stored as a uint64 in the recording format but is converted to Number here. For sufficiently large recordings this can lose precision and cause incorrect slicing / boundary checks. Either store byteOffset as bigint and convert safely at use-sites, or assert byteOffset <= Number.MAX_SAFE_INTEGER and throw a clear error when it’s not.
| async function initialiseRecording(source: ReplayDataSource): Promise<void> { | ||
| openAbort = resetAbort(openAbort); | ||
| const { signal } = openAbort; // capture before any await | ||
|
|
||
| dataSource = source; | ||
| loadState = { status: 'loading' }; | ||
| duration = 0; | ||
| totalPdus = 0; | ||
| fetchedUntilMs = 0; | ||
| recordingMetadata = null; |
There was a problem hiding this comment.
initialiseRecording() overwrites dataSource without closing the previous one and without resetting playback state / stopping the rAF loop. If load() is called while playing, tick() can start calling fetch() before the new open() resolves, and the old data source may leak resources. Close the previous data source, stop playback (paused=true, waiting=false, seeking=false, ended=false), reset elapsed/lastTimestamp, and abort any in-flight prefetch/seek before assigning the new source.
| :global(.__irp-seekbar.interactive:hover .__irp-seekbar-track) { | ||
| height: 6px; | ||
| } |
There was a problem hiding this comment.
CSS selectors use .__irp-seekbar.interactive:hover ..., but the component sets the class name as __irp-interactive (see class:__irp-interactive). As written, the hover height change will never apply. Update the selector to match the actual class name.
| :global(.__irp-seekbar.interactive:hover .__irp-seekbar-head) { | ||
| width: 16px; | ||
| height: 16px; | ||
| } |
There was a problem hiding this comment.
Same issue as above: the selector .__irp-seekbar.interactive:hover .__irp-seekbar-head won’t match because the class name is __irp-interactive. Update the selector so the hover head size change actually applies.
Add the Rust WASM replay engine (ironrdp-web-replay) that decodes RDP session recordings (FastPath PDUs) into a canvas framebuffer, and the TypeScript adapter library (iron-replay-player-wasm) that bridges the wasm-pack output to a clean init()/ReplayBackend ES module API. Includes trimmed xtask commands (build-replay, install-replay, check-replay) for building and checking the WASM + adapter layer. The web component and demo app will be added in subsequent stacked PRs.
8f07d7e to
2ced928
Compare
There was a problem hiding this comment.
suggestion: I think it’s not worth having a new crate just for replay, instead you may add them to ironrdp-testsuite-extra (rebase on master before doing that, I made some changes to it)
There was a problem hiding this comment.
Yeah, that's fair!
I'm gonna refactor this. Thanks!
Add the <iron-replay-player> custom element that provides RDP session replay playback with adaptive buffering, seeking, and variable-speed controls. Accepts a ReplayDataSource (consumer-provided) and ReplayModule (WASM backend from the adapter layer) via props. Expands xtask build-replay/install-replay/check-replay to also build, install, and check the web component package. The demo app will be added in the next stacked PR.
Add the SvelteKit demo application that wires together the WASM engine, TS adapter, and web component. Ships two reference ReplayDataSource implementations: HttpRangeDataSource for streaming via HTTP Range requests, and LocalFileDataSource for drag-and-drop file testing. Completes the xtask build integration by expanding build-replay, install-replay, and check-replay to cover all three packages, and adding the run-replay command to start the dev server. This is the final PR in the 3-branch stacked chain.
2ced928 to
7510b23
Compare
RDP Replay Player
Hello! 👋 I'm adding replay support for our use case at Cloudflare.
This is structured as 3 commits to keep the logical separation clear. If it would be easier to review as three stacked PRs (WASM crate, web component, demo app), I'm happy to split it up! Let me know what works best.
What this adds
Browser-based playback of recorded RDP sessions. Does not include a recording mechanism, instead the player accepts PDUs through a
ReplayDataSourceinterface, and it's up to the consumer to provide the data.Commit 1: WASM decoding engine (
ironrdp-web-replay+iron-replay-player-wasm)New Rust crate compiled to WASM via
wasm-pack. Reusesironrdp-sessionFastPath andironrdp-graphicsto decode recorded PDUs and render them to a canvas in the browser.buffer.rs): Holds recorded PDUs with their timestamps in FIFO order, consumed by the replay engine during rendering.replay.rs): Main entry point that the web component from commit 2 uses. Handles rendering up to a specific point in time, seeking, canvas painting with cursor compositing, and state reset for backward seeks.process.rs): Dispatches raw bytes through server FastPath (graphics/pointers), client FastPath (mouse input), and X224 (session control).web-client/iron-replay-player-wasm/): Wraps the wasm-pack output into a clean ES module, following the same pattern asiron-remote-desktop-rdp.Tests: Integration tests in a new
ironrdp-testsuite-replaycrate covering PDU processing, seek behavior, and rendering.Commit 2: Web component (
iron-replay-player)Svelte 5 custom element that provides the playback UI. Follows the same module injection pattern as
iron-remote-desktop, receives its WASM backend and data source as props at runtime, knows nothing about RDP.src/iron-replay-player.svelte):<iron-replay-player>custom element.src/interfaces/ReplayDataSource.ts):ReplayDataSourceis passed as a prop. Allows the replay player to work with any data source (HTTP, local file, IndexedDB, WebSocket) as long as it follows the open/fetch/close contract.src/services/replay-store.svelte.ts):requestAnimationFramerender loop that callsrenderTill()on the WASM engine from commit 1 each frame. Handles playback controls (play/pause/seek/speed) and checks buffer health, fetching more data from theReplayDataSourceif needed.tests/replay-store.svelte.test.ts): Test the store state machine using fake timers and mocks.tests/browser/): Smoke tests that render the full component in Chromium and verify UI interactions (pointer events, keyboard shortcuts, overlay clicks) trigger the right store functions.Commit 3: Demo app (
iron-svelte-replay-client)SvelteKit app showing how a consumer wires the WASM backend, adapter, and web component together with real data sources. This is a reference implementation, a production consumer would implement
ReplayDataSourceagainst their own format and transport.src/lib/HttpRangeDataSource.ts):ReplayDataSourceimpl that works with byte-range requests, fetching only the bytes needed for the current playback window. Primary demo of the replay engine.src/lib/LocalFileDataSource.ts):ReplayDataSourceimpl for drag-and-drop playback. Reads the full file into memory, serves PDUs as zero-copy subviews.src/lib/recordingFormat.ts): Custom binary format (header + index table + PDU data) explained in detail in the component README (web-client/iron-replay-player/README.md).scripts/replay-server.mjs): Serves recordings locally with HTTP Range and CORS support.Build integration
New
cargo xtask websubcommands, following the existinginstall/build/checkpattern:install-replay: runsnpm installfor the adapter, component, and demo appbuild-replay: compiles the WASM crate, patches the generated JS for Vite, builds all three packagescheck-replay: dev-mode build, type checking, linting, and unit tests for all packagesrun-replay: starts the SvelteKit dev serverRender loop
sequenceDiagram participant rAF as requestAnimationFrame participant Store as replay-store participant DS as DataSource participant WASM as WASM Replay participant Canvas as Canvas participant UI as SeekBar / Controls loop Every Frame (~16ms) rAF->>Store: tick(timestamp) alt Buffer low Store->>DS: fetch(fromMs, toMs, signal) DS-->>Store: ReplayPdu[] Store->>WASM: pushPdu() for each PDU end Store->>Store: elapsed += delta x speed Store->>WASM: renderTill(elapsed) WASM->>WASM: decode buffered PDUs -> framebuffer WASM->>Canvas: putImageData + cursor composite WASM-->>Store: RenderResult Store->>UI: update elapsed Store->>rAF: requestAnimationFrame(tick) endTest plan
cargo xtask check fmt -vcargo xtask check lints -vcargo xtask check tests -v(includes replay integration tests)cargo xtask web build-replaycargo xtask web check-replay(type checking, linting, unit tests)cd web-client/iron-replay-player && npm run test:browser(browser tests, Playwright)cd web-client/iron-replay-player && npm run check(svelte-check)cargo xtask web check-replayruns linting, type checking, and unit tests for all three packages.