Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/publish-libraries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,16 @@ 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
- library: multi-video-player
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 }}
Expand Down
19 changes: 19 additions & 0 deletions webapp/packages/web-recorder/build.ps1
Original file line number Diff line number Diff line change
@@ -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
}
31 changes: 31 additions & 0 deletions webapp/packages/web-recorder/package.dist.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
18 changes: 18 additions & 0 deletions webapp/packages/web-recorder/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
207 changes: 207 additions & 0 deletions webapp/packages/web-recorder/src/asciicast-v2-recorder.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void>((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();
}
}
}
11 changes: 11 additions & 0 deletions webapp/packages/web-recorder/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
5 changes: 5 additions & 0 deletions webapp/packages/web-recorder/src/recordable-session.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading