From f556fa42a44e34f02dc0f903d75ed0999714e8ad Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 12 Jun 2026 15:26:56 -0400 Subject: [PATCH 01/10] feat(webapp): add @devolutions/web-recorder session capture library Framework-agnostic browser session-recording capture (WebM video via canvas.captureStream + MediaRecorder, plus asciicast terminal) that pushes to the Gateway /jet/jrec/push endpoint. It lives beside the playback packages (shadow-player, multi-video-player) and ships on the same publish-libraries workflow. Intended as the single source of truth to replace the duplicated recorder copies currently in DVLS web and Hub web (consumer migration is a follow-up). Ref: DVLS-14621 --- .github/workflows/publish-libraries.yml | 4 +- webapp/packages/web-recorder/build.ps1 | 19 ++ .../packages/web-recorder/package.dist.json | 34 +++ webapp/packages/web-recorder/package.json | 23 ++ .../web-recorder/src/ascast-v2-recorder.ts | 94 ++++++++ webapp/packages/web-recorder/src/index.ts | 5 + .../web-recorder/src/recordable-session.ts | 5 + .../web-recorder/src/webm-recorder.ts | 226 ++++++++++++++++++ webapp/packages/web-recorder/tsconfig.json | 25 ++ webapp/packages/web-recorder/vite.config.ts | 78 ++++++ webapp/pnpm-lock.yaml | 18 ++ 11 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 webapp/packages/web-recorder/build.ps1 create mode 100644 webapp/packages/web-recorder/package.dist.json create mode 100644 webapp/packages/web-recorder/package.json create mode 100644 webapp/packages/web-recorder/src/ascast-v2-recorder.ts create mode 100644 webapp/packages/web-recorder/src/index.ts create mode 100644 webapp/packages/web-recorder/src/recordable-session.ts create mode 100644 webapp/packages/web-recorder/src/webm-recorder.ts create mode 100644 webapp/packages/web-recorder/tsconfig.json create mode 100644 webapp/packages/web-recorder/vite.config.ts diff --git a/.github/workflows/publish-libraries.yml b/.github/workflows/publish-libraries.yml index 519d8da24..4a2d4a53a 100644 --- a/.github/workflows/publish-libraries.yml +++ b/.github/workflows/publish-libraries.yml @@ -95,7 +95,7 @@ jobs: strategy: fail-fast: false matrix: - library: [ ts-angular-client, multi-video-player, shadow-player ] + library: [ ts-angular-client, multi-video-player, shadow-player, web-recorder ] include: - library: ts-angular-client libpath: ./devolutions-gateway/openapi/ts-angular-client @@ -103,6 +103,8 @@ jobs: libpath: ./webapp/packages/multi-video-player - library: shadow-player libpath: ./webapp/packages/shadow-player + - library: web-recorder + libpath: ./webapp/packages/web-recorder steps: - name: Check out ${{ github.repository }} diff --git a/webapp/packages/web-recorder/build.ps1 b/webapp/packages/web-recorder/build.ps1 new file mode 100644 index 000000000..b88b2a8fc --- /dev/null +++ b/webapp/packages/web-recorder/build.ps1 @@ -0,0 +1,19 @@ +#!/usr/bin/env pwsh + +$ErrorActionPreference = "Stop" + +Push-Location -Path $PSScriptRoot + +try +{ + pnpm install + + pnpm --filter @devolutions/web-recorder... build + + Set-Location -Path ./dist/ + npm pack +} +finally +{ + Pop-Location +} diff --git a/webapp/packages/web-recorder/package.dist.json b/webapp/packages/web-recorder/package.dist.json new file mode 100644 index 000000000..d04f281d4 --- /dev/null +++ b/webapp/packages/web-recorder/package.dist.json @@ -0,0 +1,34 @@ +{ + "name": "@devolutions/web-recorder", + "version": "0.1.0", + "description": "Framework-agnostic browser session-recording capture (WebM video + asciicast terminal) for Devolutions Gateway jrec push", + "type": "module", + "main": "./index.js", + "module": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "import": "./index.js", + "types": "./index.d.ts" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/Devolutions/devolutions-gateway.git" + }, + "keywords": [ + "recording", + "session-recording", + "webm", + "asciicast", + "capture", + "media-recorder" + ], + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/webapp/packages/web-recorder/package.json b/webapp/packages/web-recorder/package.json new file mode 100644 index 000000000..423df5645 --- /dev/null +++ b/webapp/packages/web-recorder/package.json @@ -0,0 +1,23 @@ +{ + "name": "@devolutions/web-recorder", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "devDependencies": { + "rxjs": "^7.8.1", + "typescript": "~5.6.2", + "vite": "^5.4.9", + "vite-plugin-dts": "^4.3.0", + "vite-plugin-static-copy": "^2.3.0" + } +} diff --git a/webapp/packages/web-recorder/src/ascast-v2-recorder.ts b/webapp/packages/web-recorder/src/ascast-v2-recorder.ts new file mode 100644 index 000000000..374b98f47 --- /dev/null +++ b/webapp/packages/web-recorder/src/ascast-v2-recorder.ts @@ -0,0 +1,94 @@ +export interface AssciiCastV2Header { + version: 2; + width: number; + height: number; + timestamp: number; + env: {[key: string]: string}; +} + +export type AsciiCastV2EventCode = 'o' | 'i' | 'r'; + +export type AsciinCastV2Event = [number, 'o' | 'i' | 'r', string]; +export type AsciiCastV2Event = AssciiCastV2Header | AsciinCastV2Event; + +export class AsciiCastV2Recorder { + private startTime = 0; + private websocket: QueuedWebsocket | null = null; + + constructor( + private initConfig: { + wsUrl: URL; + cols: number; + rows: number; + env: {[key: string]: string}; + terminal: { + onServerOutput: (callback: (data: string) => void) => void; + }; + }, + ) {} + + public start() { + const {wsUrl, cols, rows, env, terminal} = this.initConfig; + wsUrl.searchParams.set('fileType', 'asciicast'); + this.websocket = new QueuedWebsocket(wsUrl.toString()); + this.startTime = Date.now(); + this.send({ + version: 2, + timestamp: this.startTime, + width: cols, + height: rows, + env, + }); + + terminal.onServerOutput(data => { + // Convert \n to \r\n for proper terminal recording + const normalizedData = data.replace(/\r?\n/g, '\r\n'); + this.onEvent('o', normalizedData); + }); + } + + public stop() { + this.websocket?.close(); + } + + private onEvent(eventCode: AsciiCastV2EventCode, data: string) { + const date = (Date.now() - this.startTime) / 1000; + this.send([date, eventCode, data]); + } + + private send(data: AsciiCastV2Event | AssciiCastV2Header) { + this.websocket?.send(JSON.stringify(data) + '\n'); + } +} + +class QueuedWebsocket { + private ws: WebSocket; + private queue: string[] = []; + private ready = false; + + constructor(url: string) { + this.ws = new WebSocket(url); + this.ws.onopen = () => { + this.ready = true; + for (const data of this.queue) { + this.ws.send(data); + } + this.queue = []; + }; + } + + public send(data: string) { + if (this.ready) { + this.ws.send(data); + } else { + this.queue.push(data); + } + } + + public close() { + // Only close if not already closing or closed + if (this.ws.readyState !== WebSocket.CLOSING && this.ws.readyState !== WebSocket.CLOSED) { + this.ws.close(); + } + } +} diff --git a/webapp/packages/web-recorder/src/index.ts b/webapp/packages/web-recorder/src/index.ts new file mode 100644 index 000000000..3ab11b15f --- /dev/null +++ b/webapp/packages/web-recorder/src/index.ts @@ -0,0 +1,5 @@ +export {WebMRecorder} from './webm-recorder'; +export type {WebMRecorderOptions, WebMRecorderTelemetryEvent} from './webm-recorder'; +export {AsciiCastV2Recorder} from './ascast-v2-recorder'; +export type {AssciiCastV2Header, AsciiCastV2Event, AsciinCastV2Event, AsciiCastV2EventCode} from './ascast-v2-recorder'; +export type {IRecordableSession} from './recordable-session'; diff --git a/webapp/packages/web-recorder/src/recordable-session.ts b/webapp/packages/web-recorder/src/recordable-session.ts new file mode 100644 index 000000000..eb6fbc2b8 --- /dev/null +++ b/webapp/packages/web-recorder/src/recordable-session.ts @@ -0,0 +1,5 @@ +// The minimal contract a session must satisfy to be recorded. The recorder itself only needs to +// know a recording was requested; richer per-app session shapes (DVLS/Hub) structurally satisfy this. +export interface IRecordableSession { + shouldStartRecording: boolean; +} diff --git a/webapp/packages/web-recorder/src/webm-recorder.ts b/webapp/packages/web-recorder/src/webm-recorder.ts new file mode 100644 index 000000000..3db871c46 --- /dev/null +++ b/webapp/packages/web-recorder/src/webm-recorder.ts @@ -0,0 +1,226 @@ +import {Observable, Subject} from 'rxjs'; + +const CanvasStreamFPS = 8; +const MediaRecorderRecordInterval = 10; + +// Telemetry is reported through an optional injected hook so the library stays framework-agnostic; +// consumers (DVLS/Hub) map these events onto their own telemetry stack. +export type WebMRecorderTelemetryEvent = 'recording-initialized' | 'recording-stopped'; + +export interface WebMRecorderOptions { + onTelemetry?: (event: WebMRecorderTelemetryEvent) => void; +} + +export class WebMRecorder { + private mediaRecorder: MediaRecorder | null = null; + private ws: WebSocket | null = null; + private subject = new Subject(); + private _isRecording = false; + private stream: MediaStream | null = null; + private canvas: HTMLCanvasElement | null = null; + private animationLoopHandle: ReturnType | null = null; + private isCleaningUp = false; + + private blobQueue: Blob[] = []; + + constructor(private readonly options: WebMRecorderOptions = {}) {} + + get isRecording() { + return this._isRecording; + } + + start(canvas: HTMLCanvasElement, recordingUrl: string): Observable { + // Prevent starting multiple recordings simultaneously + if (this._isRecording || this.isCleaningUp) { + console.warn('[WebMRecorder] Recording already in progress or cleaning up'); + return this.subject.asObservable(); + } + + // Create new Subject for each start cycle since completed Subjects cannot emit + this.subject = new Subject(); + this.canvas = canvas; + if (!this.initializeCapture(canvas)) { + console.error('Failed to initialize capture. Aborting recording.'); + throw new Error('UnableToStartRecording'); + } + return this.startStreaming(recordingUrl); + } + + stop(): void { + if (this.isCleaningUp) { + return; // Prevent circular cleanup calls + } + this.isCleaningUp = true; + + if (this._isRecording && this.mediaRecorder) { + if (this.mediaRecorder.state !== 'inactive') { + this.mediaRecorder.stop(); + } + } + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.close(); + } + + this.cleanupResources(); + this.subject.complete(); + this.isCleaningUp = false; // Reset only after all cleanup is done + + this.options.onTelemetry?.('recording-stopped'); + } + + // Initialize canvas capture stream + private initializeCapture(canvas: HTMLCanvasElement): boolean { + if (!canvas) { + console.error('IronRDP canvas not found'); + return false; + } + + this.options.onTelemetry?.('recording-initialized'); + try { + this.stream = canvas.captureStream(CanvasStreamFPS); + return true; + } catch (error) { + console.error('Failed to initialize canvas capture:', error); + return false; + } + } + + // Start streaming to WebSocket + private startStreaming(recordingUrl: string): Observable { + if (!this.stream) { + console.error('No capture stream initialized'); + this.subject.error('lblCantViewRecording'); + this.subject.complete(); + return this.subject.asObservable(); + } + + this.initializeWebSocket(recordingUrl); + return this.subject.asObservable(); + } + + private initializeWebSocket(recordingUrl: string): void { + this.ws = new WebSocket(recordingUrl + '&fileType=webm'); + this.ws.onopen = this.handleWebSocketOpen.bind(this); + this.ws.onerror = this.handleWebSocketError.bind(this); + this.ws.onclose = this.handleWebSocketClose.bind(this); + } + + private handleWebSocketOpen(): void { + if (!this.stream) { + this.subject.error('lblCantViewRecording'); + this.subject.complete(); + return; + } + + const recorder = new MediaRecorder(this.stream, {mimeType: 'video/webm'}); + this.mediaRecorder = recorder; + + recorder.onstart = this.handleMediaRecorderStart.bind(this); + recorder.ondataavailable = this.handleMediaRecorderDataAvailable.bind(this); + recorder.onstop = this.handleMediaRecorderStop.bind(this); + recorder.onerror = this.handleMediaRecorderError.bind(this); + + recorder.start(MediaRecorderRecordInterval); + + // Flush any queued blobs now that WebSocket is open + if (this.blobQueue.length > 0) { + for (const blob of this.blobQueue) { + this.ws?.send(blob); + } + this.blobQueue.length = 0; + } + } + + private handleWebSocketClose(): void { + if (!this._isRecording) { + this.subject.error('UnableToConnectToTheRecordingServer'); + } + this.subject.complete(); + } + + private handleWebSocketError(event: Event): void { + console.error('[WebMRecorder] WebSocket error:', event); + this.subject.error('ConnectionToTheRecordingServerLost'); + this.subject.complete(); + this.stop(); // Safe to call - stop() guards against circular calls + } + + // Maintain continuous frame capture by drawing transparent pixels + // This is necessary because: + // 1. Remote connections often have static content with no visual updates + // 2. Without regular frame updates, the MediaRecorder may not capture enough frames + // 3. Insufficient frame capture can lead to: + // - Gaps in the recording + // - Black screens during streaming + // Note: While setInterval is not ideal for frame timing, it provides + // a practical solution for maintaining the stream + private handleMediaRecorderStart(): void { + const animationLoop = () => { + const drawEmpty = () => { + const ctx = this.canvas?.getContext('2d'); + if (!ctx) { + return; + } + ctx.globalAlpha = 0; + ctx.fillRect(0, 0, 1, 1); + }; + + return setInterval(drawEmpty, 1000 / CanvasStreamFPS); + }; + this.animationLoopHandle = animationLoop(); + } + + private handleMediaRecorderDataAvailable(event: BlobEvent): void { + if (!event.data || event.data.size === 0) return; + + if (!this._isRecording) { + this._isRecording = true; + this.subject.next(); + } + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(event.data); + } else { + console.warn('[WebMRecorder] WebSocket not ready, buffering data.'); + this.blobQueue.push(event.data); + } + } + + private handleMediaRecorderStop(): void { + if (!this._isRecording) return; + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.close(); + } + + this.cleanupResources(); + this.subject.complete(); + } + + private handleMediaRecorderError(error: Event): void { + console.error('[WebMRecorder] MediaRecorder encountered an error:', error); + this.subject.error('UnableToStartRecording'); + this.subject.complete(); + this.stop(); // Safe to call - stop() guards against circular calls + } + + private cleanupResources(): void { + this._isRecording = false; + + if (this.animationLoopHandle !== null) { + clearInterval(this.animationLoopHandle); + this.animationLoopHandle = null; + } + + if (this.stream) { + this.stream.getTracks().forEach(track => track.stop()); + this.stream = null; + } + + this.mediaRecorder = null; + this.ws = null; + this.blobQueue.length = 0; + // isCleaningUp flag is reset in stop() method to prevent race conditions + } +} diff --git a/webapp/packages/web-recorder/tsconfig.json b/webapp/packages/web-recorder/tsconfig.json new file mode 100644 index 000000000..f68fe7e5b --- /dev/null +++ b/webapp/packages/web-recorder/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "declaration": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/webapp/packages/web-recorder/vite.config.ts b/webapp/packages/web-recorder/vite.config.ts new file mode 100644 index 000000000..a10885683 --- /dev/null +++ b/webapp/packages/web-recorder/vite.config.ts @@ -0,0 +1,78 @@ +import path from 'node:path'; +import {UserConfig, defineConfig} from 'vite'; +import dts from 'vite-plugin-dts'; +import {viteStaticCopy} from 'vite-plugin-static-copy'; + +// Simple deep merge function +function deepMerge(target: Partial, source: T): T { + for (const key in source) { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + target[key] = deepMerge(target[key] || {}, source[key]); + } else { + target[key] = source[key]; + } + } + return target as T; +} + +const DefaultConfig: UserConfig = { + build: { + lib: { + entry: path.resolve(__dirname, 'src/index.ts'), + name: 'WebRecorder', + fileName: 'index', + formats: ['es'], + }, + rollupOptions: { + // rxjs is a peer dependency provided by the consuming app; do not bundle it. + external: ['rxjs'], + output: { + globals: {}, + }, + }, + }, +}; + +const OutDir = { + debug: 'dist', + release: 'dist', +}; + +const staticCopyPlugin = viteStaticCopy({ + targets: [ + { + src: './package.dist.json', + dest: './', + rename: 'package.json', + }, + ], +}); + +const Plugins = { + debug: [ + dts({ + rollupTypes: true, + }), + staticCopyPlugin, + ], + release: [ + dts({ + rollupTypes: true, + }), + staticCopyPlugin, + ], +}; + +export default defineConfig(({mode}) => { + const isDebug = mode === 'debug'; + console.log(`Building in mode ${mode}`); + + const config: UserConfig = deepMerge({}, DefaultConfig); + config.build = { + ...config.build, + outDir: isDebug ? OutDir.debug : OutDir.release, + }; + config.plugins = isDebug ? Plugins.debug : Plugins.release; + + return config; +}); diff --git a/webapp/pnpm-lock.yaml b/webapp/pnpm-lock.yaml index c75145d60..c312e62b1 100644 --- a/webapp/pnpm-lock.yaml +++ b/webapp/pnpm-lock.yaml @@ -257,6 +257,24 @@ importers: specifier: ^2.3.0 version: 2.3.2(vite@5.4.21(@types/node@22.19.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) + packages/web-recorder: + devDependencies: + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + typescript: + specifier: ~5.6.2 + version: 5.6.3 + vite: + specifier: ^5.4.9 + version: 5.4.21(@types/node@22.19.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1) + vite-plugin-dts: + specifier: ^4.3.0 + version: 4.5.4(@types/node@22.19.3)(rollup@4.59.0)(typescript@5.6.3)(vite@5.4.21(@types/node@22.19.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) + vite-plugin-static-copy: + specifier: ^2.3.0 + version: 2.3.2(vite@5.4.21(@types/node@22.19.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) + tools/recording-player-tester: dependencies: '@tailwindcss/vite': From 7a3f7734608b1049c0a949f1555a5aae472a2015 Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 12 Jun 2026 16:09:01 -0400 Subject: [PATCH 02/10] fix(web-recorder): drive frames with requestFrame() instead of a captureStream keepalive Use canvas.captureStream(0) (manual-frame mode) plus a timer-driven requestFrame() to push frames at a deterministic cadence, replacing the captureStream(8) + transparent-pixel ("drawEmpty") keepalive. The old keepalive relied on canvas dirty-tracking that stopped emitting frames on Edge 149 (empty/near-empty WebM -> recording-policy session kills) and also mutated the shared 2D context (globalAlpha=0). requestFrame() captures the current pixels regardless of change, so static desktops still produce a continuous stream and the frame count is controlled explicitly. Ref: DVLS-14621 --- .../web-recorder/src/webm-recorder.ts | 52 ++++++++----------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/webapp/packages/web-recorder/src/webm-recorder.ts b/webapp/packages/web-recorder/src/webm-recorder.ts index 3db871c46..288a43576 100644 --- a/webapp/packages/web-recorder/src/webm-recorder.ts +++ b/webapp/packages/web-recorder/src/webm-recorder.ts @@ -17,8 +17,8 @@ export class WebMRecorder { private subject = new Subject(); private _isRecording = false; private stream: MediaStream | null = null; - private canvas: HTMLCanvasElement | null = null; - private animationLoopHandle: ReturnType | null = null; + private frameTrack: CanvasCaptureMediaStreamTrack | null = null; + private frameTimer: ReturnType | null = null; private isCleaningUp = false; private blobQueue: Blob[] = []; @@ -29,6 +29,7 @@ export class WebMRecorder { return this._isRecording; } + // Combined method for backward compatibility start(canvas: HTMLCanvasElement, recordingUrl: string): Observable { // Prevent starting multiple recordings simultaneously if (this._isRecording || this.isCleaningUp) { @@ -38,7 +39,6 @@ export class WebMRecorder { // Create new Subject for each start cycle since completed Subjects cannot emit this.subject = new Subject(); - this.canvas = canvas; if (!this.initializeCapture(canvas)) { console.error('Failed to initialize capture. Aborting recording.'); throw new Error('UnableToStartRecording'); @@ -78,7 +78,12 @@ export class WebMRecorder { this.options.onTelemetry?.('recording-initialized'); try { - this.stream = canvas.captureStream(CanvasStreamFPS); + // Manual-frame mode (frameRate 0): the browser does NOT auto-capture. We drive frames explicitly + // with requestFrame(), so the cadence is deterministic and independent of the canvas "dirty" + // heuristic — which stopped emitting frames on Edge 149 and produced empty/near-empty WebM. + this.stream = canvas.captureStream(0); + const tracks = this.stream.getVideoTracks(); + this.frameTrack = tracks.length > 0 ? (tracks[0] as CanvasCaptureMediaStreamTrack) : null; return true; } catch (error) { console.error('Failed to initialize canvas capture:', error); @@ -146,29 +151,17 @@ export class WebMRecorder { this.stop(); // Safe to call - stop() guards against circular calls } - // Maintain continuous frame capture by drawing transparent pixels - // This is necessary because: - // 1. Remote connections often have static content with no visual updates - // 2. Without regular frame updates, the MediaRecorder may not capture enough frames - // 3. Insufficient frame capture can lead to: - // - Gaps in the recording - // - Black screens during streaming - // Note: While setInterval is not ideal for frame timing, it provides - // a practical solution for maintaining the stream + // Drive frames explicitly at a fixed cadence. requestFrame() captures the canvas's current pixels + // whether or not they changed, so even a static remote desktop yields a continuous, well-formed + // stream — and the frame count is controlled here rather than via canvas-mutation side effects. private handleMediaRecorderStart(): void { - const animationLoop = () => { - const drawEmpty = () => { - const ctx = this.canvas?.getContext('2d'); - if (!ctx) { - return; - } - ctx.globalAlpha = 0; - ctx.fillRect(0, 0, 1, 1); - }; - - return setInterval(drawEmpty, 1000 / CanvasStreamFPS); - }; - this.animationLoopHandle = animationLoop(); + // Seed one frame immediately so the WebM header + first keyframe flush without waiting a tick. + this.pushFrame(); + this.frameTimer = setInterval(() => this.pushFrame(), 1000 / CanvasStreamFPS); + } + + private pushFrame(): void { + this.frameTrack?.requestFrame(); } private handleMediaRecorderDataAvailable(event: BlobEvent): void { @@ -208,9 +201,9 @@ export class WebMRecorder { private cleanupResources(): void { this._isRecording = false; - if (this.animationLoopHandle !== null) { - clearInterval(this.animationLoopHandle); - this.animationLoopHandle = null; + if (this.frameTimer !== null) { + clearInterval(this.frameTimer); + this.frameTimer = null; } if (this.stream) { @@ -218,6 +211,7 @@ export class WebMRecorder { this.stream = null; } + this.frameTrack = null; this.mediaRecorder = null; this.ws = null; this.blobQueue.length = 0; From 8e4a73bff1e5ce936e5a1258eb6e0513eff3c037 Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 12 Jun 2026 17:34:39 -0400 Subject: [PATCH 03/10] fix(web-recorder): keep captureStream auto mode; use a real-pixel dirty keepalive captureStream(0) + requestFrame() (manual mode) does not feed MediaRecorder in Chromium -- it yields zero frames (verified empirically: 0 bytes vs 68 KB for auto mode), which surfaced as the recorder hanging on "waiting for the first frame". Revert to captureStream(8) (automatic capture) and replace the static-content keepalive's zero-alpha no-op (elided by Edge 149 dirty-tracking -> empty WebM) with a real 1px value change, wrapped in save()/restore() so it cannot leak globalAlpha onto the shared 2D context. Ref: DVLS-14621 --- .../web-recorder/src/webm-recorder.ts | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/webapp/packages/web-recorder/src/webm-recorder.ts b/webapp/packages/web-recorder/src/webm-recorder.ts index 288a43576..cd21d0218 100644 --- a/webapp/packages/web-recorder/src/webm-recorder.ts +++ b/webapp/packages/web-recorder/src/webm-recorder.ts @@ -17,8 +17,9 @@ export class WebMRecorder { private subject = new Subject(); private _isRecording = false; private stream: MediaStream | null = null; - private frameTrack: CanvasCaptureMediaStreamTrack | null = null; + private canvas: HTMLCanvasElement | null = null; private frameTimer: ReturnType | null = null; + private keepaliveTick = false; private isCleaningUp = false; private blobQueue: Blob[] = []; @@ -39,6 +40,7 @@ export class WebMRecorder { // Create new Subject for each start cycle since completed Subjects cannot emit this.subject = new Subject(); + this.canvas = canvas; if (!this.initializeCapture(canvas)) { console.error('Failed to initialize capture. Aborting recording.'); throw new Error('UnableToStartRecording'); @@ -78,12 +80,10 @@ export class WebMRecorder { this.options.onTelemetry?.('recording-initialized'); try { - // Manual-frame mode (frameRate 0): the browser does NOT auto-capture. We drive frames explicitly - // with requestFrame(), so the cadence is deterministic and independent of the canvas "dirty" - // heuristic — which stopped emitting frames on Edge 149 and produced empty/near-empty WebM. - this.stream = canvas.captureStream(0); - const tracks = this.stream.getVideoTracks(); - this.frameTrack = tracks.length > 0 ? (tracks[0] as CanvasCaptureMediaStreamTrack) : null; + // Automatic capture, throttled to CanvasStreamFPS: the browser emits a frame whenever the canvas + // is modified. (Manual mode — captureStream(0) + requestFrame() — does NOT feed MediaRecorder + // reliably; it produces zero frames in Chromium, so we keep the canvas "dirty" instead.) + this.stream = canvas.captureStream(CanvasStreamFPS); return true; } catch (error) { console.error('Failed to initialize canvas capture:', error); @@ -151,17 +151,27 @@ export class WebMRecorder { this.stop(); // Safe to call - stop() guards against circular calls } - // Drive frames explicitly at a fixed cadence. requestFrame() captures the canvas's current pixels - // whether or not they changed, so even a static remote desktop yields a continuous, well-formed - // stream — and the frame count is controlled here rather than via canvas-mutation side effects. + // captureStream only emits a frame when the canvas is *modified*. Remote desktops are often static, + // so without this nudge the stream stalls (gaps / black screens). setInterval is not ideal for frame + // timing but is a practical keepalive. private handleMediaRecorderStart(): void { - // Seed one frame immediately so the WebM header + first keyframe flush without waiting a tick. - this.pushFrame(); - this.frameTimer = setInterval(() => this.pushFrame(), 1000 / CanvasStreamFPS); + this.frameTimer = setInterval(() => this.keepCanvasLive(), 1000 / CanvasStreamFPS); } - private pushFrame(): void { - this.frameTrack?.requestFrame(); + // Nudge a single corner pixel with a *real* value change every tick. A zero-alpha / no-op draw is + // elided by some engines' dirty-tracking (Edge 149 → empty WebM), so we alternate the pixel value. + // save()/restore() keeps this from leaking state onto the canvas's shared 2D context. + private keepCanvasLive(): void { + const ctx = this.canvas?.getContext('2d'); + if (!ctx) { + return; + } + ctx.save(); + ctx.globalAlpha = 1; + ctx.fillStyle = this.keepaliveTick ? '#000000' : '#000001'; + ctx.fillRect(0, 0, 1, 1); + ctx.restore(); + this.keepaliveTick = !this.keepaliveTick; } private handleMediaRecorderDataAvailable(event: BlobEvent): void { @@ -211,7 +221,7 @@ export class WebMRecorder { this.stream = null; } - this.frameTrack = null; + this.canvas = null; this.mediaRecorder = null; this.ws = null; this.blobQueue.length = 0; From 913391f5dee2e84d5ce4f8adb06e2bdf13e8a080 Mon Sep 17 00:00:00 2001 From: irving ou Date: Fri, 12 Jun 2026 18:02:33 -0400 Subject: [PATCH 04/10] fix(web-recorder): drive the dirty-keepalive from requestAnimationFrame captureStream only captures a frame when the canvas is composited, and compositing is driven by the rAF/vsync loop -- NOT by setInterval. With static content (no app animation) a setInterval keepalive marks the canvas dirty but it is never presented, so zero frames are captured (verified empirically on a static canvas: setInterval -> 0 frames, rAF -> 15 frames/2s). Run the 1px dirty-nudge inside requestAnimationFrame so the compositor ticks and frames flow even when the recorded canvas is static. Ref: DVLS-14621 --- .../web-recorder/src/webm-recorder.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/webapp/packages/web-recorder/src/webm-recorder.ts b/webapp/packages/web-recorder/src/webm-recorder.ts index cd21d0218..2583eec3e 100644 --- a/webapp/packages/web-recorder/src/webm-recorder.ts +++ b/webapp/packages/web-recorder/src/webm-recorder.ts @@ -18,7 +18,7 @@ export class WebMRecorder { private _isRecording = false; private stream: MediaStream | null = null; private canvas: HTMLCanvasElement | null = null; - private frameTimer: ReturnType | null = null; + private keepaliveRaf: number | null = null; private keepaliveTick = false; private isCleaningUp = false; @@ -151,17 +151,24 @@ export class WebMRecorder { this.stop(); // Safe to call - stop() guards against circular calls } - // captureStream only emits a frame when the canvas is *modified*. Remote desktops are often static, - // so without this nudge the stream stalls (gaps / black screens). setInterval is not ideal for frame - // timing but is a practical keepalive. + // captureStream only captures a frame when the canvas is *composited*, and compositing is driven by + // the requestAnimationFrame / vsync loop — NOT by setInterval. With static content (no app animation) + // a setInterval keepalive marks the canvas dirty but it is never presented, so zero frames are + // captured (verified empirically: setInterval -> 0 frames, rAF -> frames). Drive the keepalive from + // rAF so the compositor ticks, and make a real change each frame so a fresh frame is always ready. private handleMediaRecorderStart(): void { - this.frameTimer = setInterval(() => this.keepCanvasLive(), 1000 / CanvasStreamFPS); + const tick = (): void => { + this.nudgeCanvas(); + this.keepaliveRaf = requestAnimationFrame(tick); + }; + this.keepaliveRaf = requestAnimationFrame(tick); } - // Nudge a single corner pixel with a *real* value change every tick. A zero-alpha / no-op draw is - // elided by some engines' dirty-tracking (Edge 149 → empty WebM), so we alternate the pixel value. - // save()/restore() keeps this from leaking state onto the canvas's shared 2D context. - private keepCanvasLive(): void { + // Nudge a single corner pixel with a *real* value change. A zero-alpha / no-op draw is elided by + // some engines' dirty-tracking (Edge 149 → empty WebM), so we alternate the pixel value. captureStream + // throttles the actual capture to CanvasStreamFPS regardless of the rAF rate. save()/restore() keeps + // this from leaking state onto the canvas's shared 2D context. + private nudgeCanvas(): void { const ctx = this.canvas?.getContext('2d'); if (!ctx) { return; @@ -211,9 +218,9 @@ export class WebMRecorder { private cleanupResources(): void { this._isRecording = false; - if (this.frameTimer !== null) { - clearInterval(this.frameTimer); - this.frameTimer = null; + if (this.keepaliveRaf !== null) { + cancelAnimationFrame(this.keepaliveRaf); + this.keepaliveRaf = null; } if (this.stream) { From 69a8c2b4813ec75177322ea4965cad065021940a Mon Sep 17 00:00:00 2001 From: irving ou Date: Tue, 16 Jun 2026 14:06:16 -0400 Subject: [PATCH 05/10] feat(web-recorder): add @devolutions/web-recorder session capture library Framework-agnostic browser library that records WebM video (canvas via captureStream + MediaRecorder) and asciicast v2 terminal sessions over WebSocket to a Devolutions Gateway jrec endpoint. Public API uses Promise (no RxJS dependency). Build emits .d.ts via vite-plugin-dts + tsconfig.declaration.json. CI publish already configured in publish-libraries.yml. --- .../packages/web-recorder/package.dist.json | 3 - webapp/packages/web-recorder/package.json | 7 +- .../web-recorder/src/ascast-v2-recorder.ts | 94 ------ .../web-recorder/src/asciicast-v2-recorder.ts | 202 +++++++++++++ webapp/packages/web-recorder/src/index.ts | 16 +- .../web-recorder/src/webm-recorder.ts | 286 ++++++++++++------ .../web-recorder/tsconfig.declaration.json | 11 + webapp/packages/web-recorder/vite.config.ts | 69 +---- webapp/pnpm-lock.yaml | 3 - 9 files changed, 432 insertions(+), 259 deletions(-) delete mode 100644 webapp/packages/web-recorder/src/ascast-v2-recorder.ts create mode 100644 webapp/packages/web-recorder/src/asciicast-v2-recorder.ts create mode 100644 webapp/packages/web-recorder/tsconfig.declaration.json diff --git a/webapp/packages/web-recorder/package.dist.json b/webapp/packages/web-recorder/package.dist.json index d04f281d4..e1d353c60 100644 --- a/webapp/packages/web-recorder/package.dist.json +++ b/webapp/packages/web-recorder/package.dist.json @@ -25,9 +25,6 @@ "media-recorder" ], "license": "MIT OR Apache-2.0", - "peerDependencies": { - "rxjs": "^7.0.0" - }, "publishConfig": { "access": "public" } diff --git a/webapp/packages/web-recorder/package.json b/webapp/packages/web-recorder/package.json index 423df5645..ba164d173 100644 --- a/webapp/packages/web-recorder/package.json +++ b/webapp/packages/web-recorder/package.json @@ -6,15 +6,10 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { - "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" - }, - "peerDependencies": { - "rxjs": "^7.0.0" + "check:write": "biome check --write ./src" }, "devDependencies": { - "rxjs": "^7.8.1", "typescript": "~5.6.2", "vite": "^5.4.9", "vite-plugin-dts": "^4.3.0", diff --git a/webapp/packages/web-recorder/src/ascast-v2-recorder.ts b/webapp/packages/web-recorder/src/ascast-v2-recorder.ts deleted file mode 100644 index 374b98f47..000000000 --- a/webapp/packages/web-recorder/src/ascast-v2-recorder.ts +++ /dev/null @@ -1,94 +0,0 @@ -export interface AssciiCastV2Header { - version: 2; - width: number; - height: number; - timestamp: number; - env: {[key: string]: string}; -} - -export type AsciiCastV2EventCode = 'o' | 'i' | 'r'; - -export type AsciinCastV2Event = [number, 'o' | 'i' | 'r', string]; -export type AsciiCastV2Event = AssciiCastV2Header | AsciinCastV2Event; - -export class AsciiCastV2Recorder { - private startTime = 0; - private websocket: QueuedWebsocket | null = null; - - constructor( - private initConfig: { - wsUrl: URL; - cols: number; - rows: number; - env: {[key: string]: string}; - terminal: { - onServerOutput: (callback: (data: string) => void) => void; - }; - }, - ) {} - - public start() { - const {wsUrl, cols, rows, env, terminal} = this.initConfig; - wsUrl.searchParams.set('fileType', 'asciicast'); - this.websocket = new QueuedWebsocket(wsUrl.toString()); - this.startTime = Date.now(); - this.send({ - version: 2, - timestamp: this.startTime, - width: cols, - height: rows, - env, - }); - - terminal.onServerOutput(data => { - // Convert \n to \r\n for proper terminal recording - const normalizedData = data.replace(/\r?\n/g, '\r\n'); - this.onEvent('o', normalizedData); - }); - } - - public stop() { - this.websocket?.close(); - } - - private onEvent(eventCode: AsciiCastV2EventCode, data: string) { - const date = (Date.now() - this.startTime) / 1000; - this.send([date, eventCode, data]); - } - - private send(data: AsciiCastV2Event | AssciiCastV2Header) { - this.websocket?.send(JSON.stringify(data) + '\n'); - } -} - -class QueuedWebsocket { - private ws: WebSocket; - private queue: string[] = []; - private ready = false; - - constructor(url: string) { - this.ws = new WebSocket(url); - this.ws.onopen = () => { - this.ready = true; - for (const data of this.queue) { - this.ws.send(data); - } - this.queue = []; - }; - } - - public send(data: string) { - if (this.ready) { - this.ws.send(data); - } else { - this.queue.push(data); - } - } - - public close() { - // Only close if not already closing or closed - if (this.ws.readyState !== WebSocket.CLOSING && this.ws.readyState !== WebSocket.CLOSED) { - this.ws.close(); - } - } -} diff --git a/webapp/packages/web-recorder/src/asciicast-v2-recorder.ts b/webapp/packages/web-recorder/src/asciicast-v2-recorder.ts new file mode 100644 index 000000000..84834e355 --- /dev/null +++ b/webapp/packages/web-recorder/src/asciicast-v2-recorder.ts @@ -0,0 +1,202 @@ +/** 'o' = terminal output, 'i' = keyboard input, 'r' = terminal resize */ +export type AsciiCastV2EventCode = 'o' | 'i' | 'r'; + +export interface AsciiCastV2Header { + version: 2; + width: number; + height: number; + timestamp?: number; + env?: { [key: string]: string }; +} + +export type AsciiCastV2Event = [number, AsciiCastV2EventCode, string]; +export type AsciiCastV2Message = AsciiCastV2Header | AsciiCastV2Event; + +export interface AsciiCastV2RecorderOptions { + wsUrl: URL; + cols: number; + rows: number; + env?: { [key: string]: string }; + terminal: { + /** Register a callback for server output. May return an unsubscribe function. */ + onServerOutput: (callback: (data: string) => void) => (() => void) | undefined; + }; +} + +export class AsciiCastV2Recorder { + private startTime = 0; + private websocket: QueuedWebSocket | null = null; + private outputGeneration = 0; + private unsubscribeOutput: (() => void) | null = null; + + constructor(private initConfig: AsciiCastV2RecorderOptions) {} + + // Resolves when the WebSocket opens and the asciicast header is sent. + // Rejects with an error string on any failure. + public start(): Promise { + this.internalStop(); + + const { cols, rows, env, terminal } = this.initConfig; + const wsUrl = new URL(this.initConfig.wsUrl); + wsUrl.searchParams.set('fileType', 'asciicast'); + + this.startTime = Date.now(); + const outputGeneration = ++this.outputGeneration; + + // Use definite assignment — Promise executor runs synchronously. + let settle!: (error?: string) => void; + let settled = false; + + const promise = new Promise((resolve, reject) => { + settle = (error?: string) => { + if (settled) return; + settled = true; + if (error !== undefined) { + reject(error); + } else { + resolve(); + } + }; + }); + + let connected = false; + let websocket: QueuedWebSocket | null = null; + + const fail = (): void => { + if (!websocket || this.websocket !== websocket) return; + this.outputGeneration++; + if (this.unsubscribeOutput) { + this.unsubscribeOutput(); + this.unsubscribeOutput = null; + } + this.websocket = null; + settle(connected ? 'ConnectionToTheRecordingServerLost' : 'UnableToConnectToTheRecordingServer'); + }; + + try { + websocket = new QueuedWebSocket(wsUrl.toString(), { + onOpen: () => { + if (this.websocket !== websocket) return; + connected = true; + settle(); + }, + onError: fail, + onClose: fail, + }); + } catch (error) { + console.error('[AsciiCastV2Recorder] Failed to create WebSocket:', error); + settle('UnableToConnectToTheRecordingServer'); + return promise; + } + this.websocket = websocket; + + // Header is queued until the WebSocket opens + this.send({ + version: 2, + timestamp: Math.floor(this.startTime / 1000), + width: cols, + height: rows, + env, + }); + + try { + const unsub = terminal.onServerOutput((data) => { + if (this.outputGeneration !== outputGeneration || !this.websocket) { + return; + } + // Convert \n to \r\n for proper terminal recording + const normalizedData = data.replace(/\r?\n/g, '\r\n'); + this.onEvent('o', normalizedData); + }); + this.unsubscribeOutput = typeof unsub === 'function' ? unsub : null; + } catch (error) { + console.error('[AsciiCastV2Recorder] Failed to subscribe to terminal output:', error); + const ws = this.websocket; + this.websocket = null; + ws?.close(); + settle('UnableToStartRecording'); + return promise; + } + + return promise; + } + + public stop(): void { + this.internalStop(); + } + + private internalStop(): void { + this.outputGeneration++; + if (this.unsubscribeOutput) { + this.unsubscribeOutput(); + this.unsubscribeOutput = null; + } + // Null websocket before close() so the onClose callback ignores the event + const ws = this.websocket; + this.websocket = null; + ws?.close(); + } + + private onEvent(eventCode: AsciiCastV2EventCode, data: string) { + const elapsedSeconds = (Date.now() - this.startTime) / 1000; + this.send([elapsedSeconds, eventCode, data]); + } + + private send(data: AsciiCastV2Message) { + this.websocket?.send(JSON.stringify(data) + '\n'); + } +} + +interface QueuedWebSocketCallbacks { + onOpen?: () => void; + onClose?: () => void; + onError?: () => void; +} + +class QueuedWebSocket { + private ws: WebSocket; + private queue: string[] = []; + private ready = false; + + constructor(url: string, callbacks?: QueuedWebSocketCallbacks) { + this.ws = new WebSocket(url); + this.ws.onopen = () => { + this.ready = true; + for (const data of this.queue) { + this.ws.send(data); + } + this.queue = []; + callbacks?.onOpen?.(); + }; + this.ws.onclose = () => { + this.ready = false; + this.queue = []; + callbacks?.onClose?.(); + }; + this.ws.onerror = () => { + callbacks?.onError?.(); + }; + } + + public send(data: string) { + if (this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED) { + return; + } + + if (this.ready) { + this.ws.send(data); + } else { + this.queue.push(data); + } + } + + public close() { + this.ready = false; + this.queue = []; + + // Only close if not already closing or closed + if (this.ws.readyState !== WebSocket.CLOSING && this.ws.readyState !== WebSocket.CLOSED) { + this.ws.close(); + } + } +} diff --git a/webapp/packages/web-recorder/src/index.ts b/webapp/packages/web-recorder/src/index.ts index 3ab11b15f..f48ea419d 100644 --- a/webapp/packages/web-recorder/src/index.ts +++ b/webapp/packages/web-recorder/src/index.ts @@ -1,5 +1,11 @@ -export {WebMRecorder} from './webm-recorder'; -export type {WebMRecorderOptions, WebMRecorderTelemetryEvent} from './webm-recorder'; -export {AsciiCastV2Recorder} from './ascast-v2-recorder'; -export type {AssciiCastV2Header, AsciiCastV2Event, AsciinCastV2Event, AsciiCastV2EventCode} from './ascast-v2-recorder'; -export type {IRecordableSession} from './recordable-session'; +export type { + AsciiCastV2Event, + AsciiCastV2EventCode, + AsciiCastV2Header, + AsciiCastV2Message, + AsciiCastV2RecorderOptions, +} from './asciicast-v2-recorder'; +export { AsciiCastV2Recorder } from './asciicast-v2-recorder'; +export type { IRecordableSession } from './recordable-session'; +export type { WebMRecorderOptions, WebMRecorderTelemetryEvent } from './webm-recorder'; +export { WebMRecorder } from './webm-recorder'; diff --git a/webapp/packages/web-recorder/src/webm-recorder.ts b/webapp/packages/web-recorder/src/webm-recorder.ts index 2583eec3e..45664fd3c 100644 --- a/webapp/packages/web-recorder/src/webm-recorder.ts +++ b/webapp/packages/web-recorder/src/webm-recorder.ts @@ -1,7 +1,5 @@ -import {Observable, Subject} from 'rxjs'; - -const CanvasStreamFPS = 8; -const MediaRecorderRecordInterval = 10; +const CANVAS_STREAM_FPS = 8; +const MEDIA_RECORDER_INTERVAL_MS = 10; // Telemetry is reported through an optional injected hook so the library stays framework-agnostic; // consumers (DVLS/Hub) map these events onto their own telemetry stack. @@ -14,15 +12,16 @@ export interface WebMRecorderOptions { export class WebMRecorder { private mediaRecorder: MediaRecorder | null = null; private ws: WebSocket | null = null; - private subject = new Subject(); + private resolveStart: (() => void) | null = null; + private rejectStart: ((reason: string) => void) | null = null; private _isRecording = false; private stream: MediaStream | null = null; private canvas: HTMLCanvasElement | null = null; private keepaliveRaf: number | null = null; private keepaliveTick = false; + private isStarting = false; private isCleaningUp = false; - - private blobQueue: Blob[] = []; + private pendingError: string | null = null; constructor(private readonly options: WebMRecorderOptions = {}) {} @@ -30,82 +29,99 @@ export class WebMRecorder { return this._isRecording; } - // Combined method for backward compatibility - start(canvas: HTMLCanvasElement, recordingUrl: string): Observable { - // Prevent starting multiple recordings simultaneously - if (this._isRecording || this.isCleaningUp) { + // Resolves when the first data chunk is received (recording confirmed active). + // Rejects with an error string on any failure. + start(canvas: HTMLCanvasElement, recordingUrl: string): Promise { + if (this._isRecording || this.isStarting || this.isCleaningUp) { console.warn('[WebMRecorder] Recording already in progress or cleaning up'); - return this.subject.asObservable(); + return Promise.reject('RecordingAlreadyInProgress'); } - // Create new Subject for each start cycle since completed Subjects cannot emit - this.subject = new Subject(); + const promise = new Promise((resolve, reject) => { + this.resolveStart = resolve; + this.rejectStart = reject; + }); + + this.isStarting = true; this.canvas = canvas; if (!this.initializeCapture(canvas)) { - console.error('Failed to initialize capture. Aborting recording.'); - throw new Error('UnableToStartRecording'); + this.isStarting = false; + this.rejectStart?.('UnableToStartRecording'); + this.resolveStart = null; + this.rejectStart = null; + return promise; } - return this.startStreaming(recordingUrl); + this.startStreaming(recordingUrl); + return promise; } stop(): void { if (this.isCleaningUp) { - return; // Prevent circular cleanup calls + return; } this.isCleaningUp = true; + this.stopKeepalive(); - if (this._isRecording && this.mediaRecorder) { - if (this.mediaRecorder.state !== 'inactive') { - this.mediaRecorder.stop(); - } + if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') { + this.mediaRecorder.stop(); + return; } - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.close(); - } + // Resolve any pending start promise — stop() is an intentional user action. + this.resolveStart?.(); + this.resolveStart = null; + this.rejectStart = null; + this.closeWebSocket(); this.cleanupResources(); - this.subject.complete(); - this.isCleaningUp = false; // Reset only after all cleanup is done - - this.options.onTelemetry?.('recording-stopped'); + this.fireTelemetry('recording-stopped'); } // Initialize canvas capture stream private initializeCapture(canvas: HTMLCanvasElement): boolean { if (!canvas) { - console.error('IronRDP canvas not found'); + console.error('[WebMRecorder] Canvas element is null'); return false; } - this.options.onTelemetry?.('recording-initialized'); try { - // Automatic capture, throttled to CanvasStreamFPS: the browser emits a frame whenever the canvas + // Automatic capture, throttled to CANVAS_STREAM_FPS: the browser emits a frame whenever the canvas // is modified. (Manual mode — captureStream(0) + requestFrame() — does NOT feed MediaRecorder // reliably; it produces zero frames in Chromium, so we keep the canvas "dirty" instead.) - this.stream = canvas.captureStream(CanvasStreamFPS); - return true; + this.stream = canvas.captureStream(CANVAS_STREAM_FPS); } catch (error) { console.error('Failed to initialize canvas capture:', error); return false; } + this.fireTelemetry('recording-initialized'); + return true; } - // Start streaming to WebSocket - private startStreaming(recordingUrl: string): Observable { + private startStreaming(recordingUrl: string): void { if (!this.stream) { console.error('No capture stream initialized'); - this.subject.error('lblCantViewRecording'); - this.subject.complete(); - return this.subject.asObservable(); + this.isStarting = false; + this.rejectStart?.('lblCantViewRecording'); + this.resolveStart = null; + this.rejectStart = null; + return; } - this.initializeWebSocket(recordingUrl); - return this.subject.asObservable(); + try { + this.initializeWebSocket(recordingUrl); + } catch (error) { + console.error('[WebMRecorder] Failed to create WebSocket:', error); + this.isStarting = false; + this.rejectStart?.('UnableToConnectToTheRecordingServer'); + this.resolveStart = null; + this.rejectStart = null; + this.cleanupResources(); + } } private initializeWebSocket(recordingUrl: string): void { - this.ws = new WebSocket(recordingUrl + '&fileType=webm'); + const separator = recordingUrl.includes('?') ? '&' : '?'; + this.ws = new WebSocket(`${recordingUrl}${separator}fileType=webm`); this.ws.onopen = this.handleWebSocketOpen.bind(this); this.ws.onerror = this.handleWebSocketError.bind(this); this.ws.onclose = this.handleWebSocketClose.bind(this); @@ -113,49 +129,86 @@ export class WebMRecorder { private handleWebSocketOpen(): void { if (!this.stream) { - this.subject.error('lblCantViewRecording'); - this.subject.complete(); + this.isStarting = false; + this.rejectStart?.('lblCantViewRecording'); + this.resolveStart = null; + this.rejectStart = null; + this.closeWebSocket(); + this.cleanupResources(); return; } - const recorder = new MediaRecorder(this.stream, {mimeType: 'video/webm'}); - this.mediaRecorder = recorder; - - recorder.onstart = this.handleMediaRecorderStart.bind(this); - recorder.ondataavailable = this.handleMediaRecorderDataAvailable.bind(this); - recorder.onstop = this.handleMediaRecorderStop.bind(this); - recorder.onerror = this.handleMediaRecorderError.bind(this); + try { + const recorder = new MediaRecorder(this.stream, { mimeType: 'video/webm' }); + this.mediaRecorder = recorder; - recorder.start(MediaRecorderRecordInterval); + recorder.onstart = this.handleMediaRecorderStart.bind(this); + recorder.ondataavailable = this.handleMediaRecorderDataAvailable.bind(this); + recorder.onstop = this.handleMediaRecorderStop.bind(this); + recorder.onerror = this.handleMediaRecorderError.bind(this); - // Flush any queued blobs now that WebSocket is open - if (this.blobQueue.length > 0) { - for (const blob of this.blobQueue) { - this.ws?.send(blob); - } - this.blobQueue.length = 0; + recorder.start(MEDIA_RECORDER_INTERVAL_MS); + } catch (error) { + console.error('[WebMRecorder] Failed to start MediaRecorder:', error); + this.isStarting = false; + this.rejectStart?.('UnableToStartRecording'); + this.resolveStart = null; + this.rejectStart = null; + this.closeWebSocket(); + this.cleanupResources(); } } - private handleWebSocketClose(): void { - if (!this._isRecording) { - this.subject.error('UnableToConnectToTheRecordingServer'); + private handleWebSocketClose(event: CloseEvent): void { + if (this.isCleaningUp) { + return; + } + + this.isCleaningUp = true; + this.stopKeepalive(); + + const wasRecording = this._isRecording || (this.mediaRecorder !== null && this.mediaRecorder.state !== 'inactive'); + const errorCode = wasRecording ? 'ConnectionToTheRecordingServerLost' : 'UnableToConnectToTheRecordingServer'; + + if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') { + // Defer rejection until handleMediaRecorderStop so cleanup runs first. + this.pendingError = errorCode; + this.mediaRecorder.stop(); + return; } - this.subject.complete(); + + console.warn('[WebMRecorder] WebSocket closed unexpectedly (no active recorder):', event); + this.rejectStart?.(errorCode); + this.resolveStart = null; + this.rejectStart = null; + this.cleanupResources(); } private handleWebSocketError(event: Event): void { console.error('[WebMRecorder] WebSocket error:', event); - this.subject.error('ConnectionToTheRecordingServerLost'); - this.subject.complete(); - this.stop(); // Safe to call - stop() guards against circular calls + if (this.isCleaningUp) return; + this.isCleaningUp = true; + this.stopKeepalive(); + + const wasRecording = this._isRecording || (this.mediaRecorder !== null && this.mediaRecorder.state !== 'inactive'); + const errorCode = wasRecording ? 'ConnectionToTheRecordingServerLost' : 'UnableToConnectToTheRecordingServer'; + + if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') { + this.pendingError = errorCode; + this.mediaRecorder.stop(); + return; + } + + this.rejectStart?.(errorCode); + this.resolveStart = null; + this.rejectStart = null; + this.closeWebSocket(); + this.cleanupResources(); } - // captureStream only captures a frame when the canvas is *composited*, and compositing is driven by - // the requestAnimationFrame / vsync loop — NOT by setInterval. With static content (no app animation) - // a setInterval keepalive marks the canvas dirty but it is never presented, so zero frames are - // captured (verified empirically: setInterval -> 0 frames, rAF -> frames). Drive the keepalive from - // rAF so the compositor ticks, and make a real change each frame so a fresh frame is always ready. + // Browser captureStream implementations are unreliable for static or sparsely updated canvases. + // Drive the keepalive from rAF so the nudge is aligned with the rendering pipeline, and make a real + // change each frame so captureStream always has fresh canvas content to sample. private handleMediaRecorderStart(): void { const tick = (): void => { this.nudgeCanvas(); @@ -166,7 +219,7 @@ export class WebMRecorder { // Nudge a single corner pixel with a *real* value change. A zero-alpha / no-op draw is elided by // some engines' dirty-tracking (Edge 149 → empty WebM), so we alternate the pixel value. captureStream - // throttles the actual capture to CanvasStreamFPS regardless of the rAF rate. save()/restore() keeps + // throttles the actual capture to CANVAS_STREAM_FPS regardless of the rAF rate. save()/restore() keeps // this from leaking state onto the canvas's shared 2D context. private nudgeCanvas(): void { const ctx = this.canvas?.getContext('2d'); @@ -185,53 +238,104 @@ export class WebMRecorder { if (!event.data || event.data.size === 0) return; if (!this._isRecording) { + this.isStarting = false; this._isRecording = true; - this.subject.next(); + // Resolve the start promise — first data confirms recording is active. + this.resolveStart?.(); + this.resolveStart = null; + this.rejectStart = null; } if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(event.data); - } else { - console.warn('[WebMRecorder] WebSocket not ready, buffering data.'); - this.blobQueue.push(event.data); } } private handleMediaRecorderStop(): void { - if (!this._isRecording) return; - - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.close(); + this.closeWebSocket(); + const error = this.pendingError; // read before cleanupResources() clears it + // Settle the start promise before cleanup resets isCleaningUp. + if (error) { + // Pre-start failure — recording was never confirmed to the consumer. + this.rejectStart?.(error); + } else { + // Normal stop initiated by stop() — resolveStart is already null if recording + // was confirmed, or resolveStart needs settling if stop() was called before first data. + this.resolveStart?.(); + } + this.resolveStart = null; + this.rejectStart = null; + this.cleanupResources(); // resets isCleaningUp = false + if (!error) { + this.fireTelemetry('recording-stopped'); } - - this.cleanupResources(); - this.subject.complete(); } private handleMediaRecorderError(error: Event): void { console.error('[WebMRecorder] MediaRecorder encountered an error:', error); - this.subject.error('UnableToStartRecording'); - this.subject.complete(); - this.stop(); // Safe to call - stop() guards against circular calls + if (this.isCleaningUp) return; + this.isCleaningUp = true; + this.stopKeepalive(); + // Defer rejection until handleMediaRecorderStop so cleanup completes first. + this.pendingError = 'UnableToStartRecording'; + } + + private fireTelemetry(event: WebMRecorderTelemetryEvent): void { + try { + this.options.onTelemetry?.(event); + } catch (e) { + console.warn('[WebMRecorder] onTelemetry hook threw:', e); + } } private cleanupResources(): void { this._isRecording = false; + this.isStarting = false; + this.stopKeepalive(); - if (this.keepaliveRaf !== null) { - cancelAnimationFrame(this.keepaliveRaf); - this.keepaliveRaf = null; + if (this.ws) { + this.ws.onopen = null; + this.ws.onerror = null; + this.ws.onclose = null; } if (this.stream) { - this.stream.getTracks().forEach(track => track.stop()); + this.stream.getTracks().forEach((track) => track.stop()); this.stream = null; } this.canvas = null; + if (this.mediaRecorder) { + this.mediaRecorder.onstart = null; + this.mediaRecorder.ondataavailable = null; + this.mediaRecorder.onstop = null; + this.mediaRecorder.onerror = null; + } this.mediaRecorder = null; this.ws = null; - this.blobQueue.length = 0; - // isCleaningUp flag is reset in stop() method to prevent race conditions + this.resolveStart = null; + this.rejectStart = null; + this.pendingError = null; + this.isCleaningUp = false; + } + + private stopKeepalive(): void { + if (this.keepaliveRaf !== null) { + cancelAnimationFrame(this.keepaliveRaf); + this.keepaliveRaf = null; + } + } + + private closeWebSocket(): void { + if (!this.ws) { + return; + } + + this.ws.onclose = null; + this.ws.onerror = null; + + if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { + this.ws.close(); + } } } diff --git a/webapp/packages/web-recorder/tsconfig.declaration.json b/webapp/packages/web-recorder/tsconfig.declaration.json new file mode 100644 index 000000000..c28817bc3 --- /dev/null +++ b/webapp/packages/web-recorder/tsconfig.declaration.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist" + } +} diff --git a/webapp/packages/web-recorder/vite.config.ts b/webapp/packages/web-recorder/vite.config.ts index a10885683..b990e8bf2 100644 --- a/webapp/packages/web-recorder/vite.config.ts +++ b/webapp/packages/web-recorder/vite.config.ts @@ -1,21 +1,19 @@ import path from 'node:path'; -import {UserConfig, defineConfig} from 'vite'; +import {defineConfig} from 'vite'; import dts from 'vite-plugin-dts'; import {viteStaticCopy} from 'vite-plugin-static-copy'; -// Simple deep merge function -function deepMerge(target: Partial, source: T): T { - for (const key in source) { - if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { - target[key] = deepMerge(target[key] || {}, source[key]); - } else { - target[key] = source[key]; - } - } - return target as T; -} +const staticCopyPlugin = viteStaticCopy({ + targets: [ + { + src: './package.dist.json', + dest: './', + rename: 'package.json', + }, + ], +}); -const DefaultConfig: UserConfig = { +export default defineConfig({ build: { lib: { entry: path.resolve(__dirname, 'src/index.ts'), @@ -31,48 +29,5 @@ const DefaultConfig: UserConfig = { }, }, }, -}; - -const OutDir = { - debug: 'dist', - release: 'dist', -}; - -const staticCopyPlugin = viteStaticCopy({ - targets: [ - { - src: './package.dist.json', - dest: './', - rename: 'package.json', - }, - ], -}); - -const Plugins = { - debug: [ - dts({ - rollupTypes: true, - }), - staticCopyPlugin, - ], - release: [ - dts({ - rollupTypes: true, - }), - staticCopyPlugin, - ], -}; - -export default defineConfig(({mode}) => { - const isDebug = mode === 'debug'; - console.log(`Building in mode ${mode}`); - - const config: UserConfig = deepMerge({}, DefaultConfig); - config.build = { - ...config.build, - outDir: isDebug ? OutDir.debug : OutDir.release, - }; - config.plugins = isDebug ? Plugins.debug : Plugins.release; - - return config; + plugins: [dts({tsconfigPath: './tsconfig.declaration.json'}), staticCopyPlugin], }); diff --git a/webapp/pnpm-lock.yaml b/webapp/pnpm-lock.yaml index c312e62b1..a000fb32d 100644 --- a/webapp/pnpm-lock.yaml +++ b/webapp/pnpm-lock.yaml @@ -259,9 +259,6 @@ importers: packages/web-recorder: devDependencies: - rxjs: - specifier: ^7.8.1 - version: 7.8.2 typescript: specifier: ~5.6.2 version: 5.6.3 From 6bb29d828123dcf7c1186eb38b608e938abe9db2 Mon Sep 17 00:00:00 2001 From: "irvingouj@Devolutions" Date: Tue, 16 Jun 2026 14:17:05 -0400 Subject: [PATCH 06/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- webapp/packages/web-recorder/vite.config.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/webapp/packages/web-recorder/vite.config.ts b/webapp/packages/web-recorder/vite.config.ts index b990e8bf2..f59c3984f 100644 --- a/webapp/packages/web-recorder/vite.config.ts +++ b/webapp/packages/web-recorder/vite.config.ts @@ -22,12 +22,10 @@ export default defineConfig({ formats: ['es'], }, rollupOptions: { - // rxjs is a peer dependency provided by the consuming app; do not bundle it. - external: ['rxjs'], output: { globals: {}, }, - }, + } }, plugins: [dts({tsconfigPath: './tsconfig.declaration.json'}), staticCopyPlugin], }); From 76a4d84016c702d97b70088638c208fd0e7284ca Mon Sep 17 00:00:00 2001 From: "irvingouj@Devolutions" Date: Tue, 16 Jun 2026 14:19:10 -0400 Subject: [PATCH 07/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- webapp/packages/web-recorder/src/webm-recorder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/packages/web-recorder/src/webm-recorder.ts b/webapp/packages/web-recorder/src/webm-recorder.ts index 45664fd3c..bf65e4dcd 100644 --- a/webapp/packages/web-recorder/src/webm-recorder.ts +++ b/webapp/packages/web-recorder/src/webm-recorder.ts @@ -101,7 +101,7 @@ export class WebMRecorder { if (!this.stream) { console.error('No capture stream initialized'); this.isStarting = false; - this.rejectStart?.('lblCantViewRecording'); + this.rejectStart?.('UnableToStartRecording'); this.resolveStart = null; this.rejectStart = null; return; From 33e84a25a8051c7dc5caff97ba6ca043265b07b9 Mon Sep 17 00:00:00 2001 From: "irvingouj@Devolutions" Date: Tue, 16 Jun 2026 14:19:19 -0400 Subject: [PATCH 08/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- webapp/packages/web-recorder/src/webm-recorder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/packages/web-recorder/src/webm-recorder.ts b/webapp/packages/web-recorder/src/webm-recorder.ts index bf65e4dcd..557e4ad73 100644 --- a/webapp/packages/web-recorder/src/webm-recorder.ts +++ b/webapp/packages/web-recorder/src/webm-recorder.ts @@ -130,7 +130,7 @@ export class WebMRecorder { private handleWebSocketOpen(): void { if (!this.stream) { this.isStarting = false; - this.rejectStart?.('lblCantViewRecording'); + this.rejectStart?.('UnableToStartRecording'); this.resolveStart = null; this.rejectStart = null; this.closeWebSocket(); From 4c9b7728863075121611012f504e6c2d3e0a8a5d Mon Sep 17 00:00:00 2001 From: irving ou Date: Tue, 16 Jun 2026 14:27:22 -0400 Subject: [PATCH 09/10] fix(web-recorder): require unsubscribe function from onServerOutput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the '| void' union — callers must always return a cleanup function. Cleaner contract, no biome suppression needed, prevents callback accumulation on start()/stop() cycles. --- webapp/packages/web-recorder/src/asciicast-v2-recorder.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/webapp/packages/web-recorder/src/asciicast-v2-recorder.ts b/webapp/packages/web-recorder/src/asciicast-v2-recorder.ts index 84834e355..337c28d1a 100644 --- a/webapp/packages/web-recorder/src/asciicast-v2-recorder.ts +++ b/webapp/packages/web-recorder/src/asciicast-v2-recorder.ts @@ -18,8 +18,8 @@ export interface AsciiCastV2RecorderOptions { rows: number; env?: { [key: string]: string }; terminal: { - /** Register a callback for server output. May return an unsubscribe function. */ - onServerOutput: (callback: (data: string) => void) => (() => void) | undefined; + /** Register a callback for server output. Must return an unsubscribe function. */ + onServerOutput: (callback: (data: string) => void) => () => void; }; } @@ -100,7 +100,7 @@ export class AsciiCastV2Recorder { }); try { - const unsub = terminal.onServerOutput((data) => { + this.unsubscribeOutput = terminal.onServerOutput((data) => { if (this.outputGeneration !== outputGeneration || !this.websocket) { return; } @@ -108,7 +108,6 @@ export class AsciiCastV2Recorder { const normalizedData = data.replace(/\r?\n/g, '\r\n'); this.onEvent('o', normalizedData); }); - this.unsubscribeOutput = typeof unsub === 'function' ? unsub : null; } catch (error) { console.error('[AsciiCastV2Recorder] Failed to subscribe to terminal output:', error); const ws = this.websocket; From ef9fd1e7f5ec4ee247dd0e46f50cdbc958b4cb8f Mon Sep 17 00:00:00 2001 From: irving ou Date: Tue, 16 Jun 2026 14:52:54 -0400 Subject: [PATCH 10/10] fix(web-recorder): address Copilot review findings - handleMediaRecorderError: explicitly stop MediaRecorder so onstop always fires (browsers don't guarantee it after onerror) - AsciiCastV2Recorder: store settle on class field so internalStop() can resolve the pending start Promise when stop() is called before the WebSocket opens --- webapp/packages/web-recorder/src/asciicast-v2-recorder.ts | 8 +++++++- webapp/packages/web-recorder/src/webm-recorder.ts | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/webapp/packages/web-recorder/src/asciicast-v2-recorder.ts b/webapp/packages/web-recorder/src/asciicast-v2-recorder.ts index 337c28d1a..4e5bf32a5 100644 --- a/webapp/packages/web-recorder/src/asciicast-v2-recorder.ts +++ b/webapp/packages/web-recorder/src/asciicast-v2-recorder.ts @@ -28,6 +28,7 @@ export class AsciiCastV2Recorder { private websocket: QueuedWebSocket | null = null; private outputGeneration = 0; private unsubscribeOutput: (() => void) | null = null; + private settleStart: ((error?: string) => void) | null = null; constructor(private initConfig: AsciiCastV2RecorderOptions) {} @@ -51,6 +52,7 @@ export class AsciiCastV2Recorder { settle = (error?: string) => { if (settled) return; settled = true; + this.settleStart = null; if (error !== undefined) { reject(error); } else { @@ -58,6 +60,7 @@ export class AsciiCastV2Recorder { } }; }); + this.settleStart = settle; let connected = false; let websocket: QueuedWebSocket | null = null; @@ -130,7 +133,10 @@ export class AsciiCastV2Recorder { this.unsubscribeOutput(); this.unsubscribeOutput = null; } - // Null websocket before close() so the onClose callback ignores the event + // Settle any pending start promise before closing — stop() is intentional so resolve, not reject. + this.settleStart?.(); + this.settleStart = null; + // Null websocket before close() so the onClose callback ignores the event. const ws = this.websocket; this.websocket = null; ws?.close(); diff --git a/webapp/packages/web-recorder/src/webm-recorder.ts b/webapp/packages/web-recorder/src/webm-recorder.ts index 557e4ad73..a80ca446e 100644 --- a/webapp/packages/web-recorder/src/webm-recorder.ts +++ b/webapp/packages/web-recorder/src/webm-recorder.ts @@ -276,8 +276,11 @@ export class WebMRecorder { if (this.isCleaningUp) return; this.isCleaningUp = true; this.stopKeepalive(); - // Defer rejection until handleMediaRecorderStop so cleanup completes first. this.pendingError = 'UnableToStartRecording'; + // Explicitly stop so onstop always fires — browsers don't guarantee it after onerror. + if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') { + this.mediaRecorder.stop(); + } } private fireTelemetry(event: WebMRecorderTelemetryEvent): void {