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..e1d353c60 --- /dev/null +++ b/webapp/packages/web-recorder/package.dist.json @@ -0,0 +1,31 @@ +{ + "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", + "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..ba164d173 --- /dev/null +++ b/webapp/packages/web-recorder/package.json @@ -0,0 +1,18 @@ +{ + "name": "@devolutions/web-recorder", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc && vite build", + "check:write": "biome check --write ./src" + }, + "devDependencies": { + "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/asciicast-v2-recorder.ts b/webapp/packages/web-recorder/src/asciicast-v2-recorder.ts new file mode 100644 index 000000000..4e5bf32a5 --- /dev/null +++ b/webapp/packages/web-recorder/src/asciicast-v2-recorder.ts @@ -0,0 +1,207 @@ +/** '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. Must return an unsubscribe function. */ + onServerOutput: (callback: (data: string) => void) => () => void; + }; +} + +export class AsciiCastV2Recorder { + private startTime = 0; + private websocket: QueuedWebSocket | null = null; + private outputGeneration = 0; + private unsubscribeOutput: (() => void) | null = null; + private settleStart: ((error?: string) => 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; + this.settleStart = null; + if (error !== undefined) { + reject(error); + } else { + resolve(); + } + }; + }); + this.settleStart = settle; + + 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 { + this.unsubscribeOutput = 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); + }); + } 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; + } + // 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(); + } + + 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 new file mode 100644 index 000000000..f48ea419d --- /dev/null +++ b/webapp/packages/web-recorder/src/index.ts @@ -0,0 +1,11 @@ +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/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..a80ca446e --- /dev/null +++ b/webapp/packages/web-recorder/src/webm-recorder.ts @@ -0,0 +1,344 @@ +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. +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 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 pendingError: string | null = null; + + constructor(private readonly options: WebMRecorderOptions = {}) {} + + get isRecording() { + return this._isRecording; + } + + // 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 Promise.reject('RecordingAlreadyInProgress'); + } + + const promise = new Promise((resolve, reject) => { + this.resolveStart = resolve; + this.rejectStart = reject; + }); + + this.isStarting = true; + this.canvas = canvas; + if (!this.initializeCapture(canvas)) { + this.isStarting = false; + this.rejectStart?.('UnableToStartRecording'); + this.resolveStart = null; + this.rejectStart = null; + return promise; + } + this.startStreaming(recordingUrl); + return promise; + } + + stop(): void { + if (this.isCleaningUp) { + return; + } + this.isCleaningUp = true; + this.stopKeepalive(); + + if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') { + this.mediaRecorder.stop(); + return; + } + + // Resolve any pending start promise — stop() is an intentional user action. + this.resolveStart?.(); + this.resolveStart = null; + this.rejectStart = null; + + this.closeWebSocket(); + this.cleanupResources(); + this.fireTelemetry('recording-stopped'); + } + + // Initialize canvas capture stream + private initializeCapture(canvas: HTMLCanvasElement): boolean { + if (!canvas) { + console.error('[WebMRecorder] Canvas element is null'); + return false; + } + + try { + // 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(CANVAS_STREAM_FPS); + } catch (error) { + console.error('Failed to initialize canvas capture:', error); + return false; + } + this.fireTelemetry('recording-initialized'); + return true; + } + + private startStreaming(recordingUrl: string): void { + if (!this.stream) { + console.error('No capture stream initialized'); + this.isStarting = false; + this.rejectStart?.('UnableToStartRecording'); + this.resolveStart = null; + this.rejectStart = null; + return; + } + + 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 { + 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); + } + + private handleWebSocketOpen(): void { + if (!this.stream) { + this.isStarting = false; + this.rejectStart?.('UnableToStartRecording'); + this.resolveStart = null; + this.rejectStart = null; + this.closeWebSocket(); + this.cleanupResources(); + return; + } + + try { + 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(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(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; + } + + 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); + 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(); + } + + // 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(); + this.keepaliveRaf = requestAnimationFrame(tick); + }; + this.keepaliveRaf = requestAnimationFrame(tick); + } + + // 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 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'); + 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 { + if (!event.data || event.data.size === 0) return; + + if (!this._isRecording) { + this.isStarting = false; + this._isRecording = true; + // 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); + } + } + + private handleMediaRecorderStop(): void { + 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'); + } + } + + private handleMediaRecorderError(error: Event): void { + console.error('[WebMRecorder] MediaRecorder encountered an error:', error); + if (this.isCleaningUp) return; + this.isCleaningUp = true; + this.stopKeepalive(); + 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 { + 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.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 = 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.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/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..f59c3984f --- /dev/null +++ b/webapp/packages/web-recorder/vite.config.ts @@ -0,0 +1,31 @@ +import path from 'node:path'; +import {defineConfig} from 'vite'; +import dts from 'vite-plugin-dts'; +import {viteStaticCopy} from 'vite-plugin-static-copy'; + +const staticCopyPlugin = viteStaticCopy({ + targets: [ + { + src: './package.dist.json', + dest: './', + rename: 'package.json', + }, + ], +}); + +export default defineConfig({ + build: { + lib: { + entry: path.resolve(__dirname, 'src/index.ts'), + name: 'WebRecorder', + fileName: 'index', + formats: ['es'], + }, + rollupOptions: { + output: { + globals: {}, + }, + } + }, + plugins: [dts({tsconfigPath: './tsconfig.declaration.json'}), staticCopyPlugin], +}); diff --git a/webapp/pnpm-lock.yaml b/webapp/pnpm-lock.yaml index c75145d60..a000fb32d 100644 --- a/webapp/pnpm-lock.yaml +++ b/webapp/pnpm-lock.yaml @@ -257,6 +257,21 @@ 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: + 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':