@devolutions/web-recorder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ /** 'o' = terminal output, 'i' = keyboard input, 'r' = terminal resize */
2
+ export type AsciiCastV2EventCode = 'o' | 'i' | 'r';
3
+ export interface AsciiCastV2Header {
4
+ version: 2;
5
+ width: number;
6
+ height: number;
7
+ timestamp?: number;
8
+ env?: {
9
+ [key: string]: string;
10
+ };
11
+ }
12
+ export type AsciiCastV2Event = [number, AsciiCastV2EventCode, string];
13
+ export type AsciiCastV2Message = AsciiCastV2Header | AsciiCastV2Event;
14
+ export interface AsciiCastV2RecorderOptions {
15
+ wsUrl: URL;
16
+ cols: number;
17
+ rows: number;
18
+ env?: {
19
+ [key: string]: string;
20
+ };
21
+ terminal: {
22
+ /** Register a callback for server output. Must return an unsubscribe function. */
23
+ onServerOutput: (callback: (data: string) => void) => () => void;
24
+ };
25
+ }
26
+ export declare class AsciiCastV2Recorder {
27
+ private initConfig;
28
+ private startTime;
29
+ private websocket;
30
+ private outputGeneration;
31
+ private unsubscribeOutput;
32
+ private settleStart;
33
+ constructor(initConfig: AsciiCastV2RecorderOptions);
34
+ start(): Promise<void>;
35
+ stop(): void;
36
+ private internalStop;
37
+ private onEvent;
38
+ private send;
39
+ }
package/index.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export type { AsciiCastV2Event, AsciiCastV2EventCode, AsciiCastV2Header, AsciiCastV2Message, AsciiCastV2RecorderOptions, } from './asciicast-v2-recorder';
2
+ export { AsciiCastV2Recorder } from './asciicast-v2-recorder';
3
+ export type { IRecordableSession } from './recordable-session';
4
+ export type { WebMRecorderOptions, WebMRecorderTelemetryEvent } from './webm-recorder';
5
+ export { WebMRecorder } from './webm-recorder';
package/index.js ADDED
@@ -0,0 +1,265 @@
1
+ var g = Object.defineProperty;
2
+ var m = (o, e, t) => e in o ? g(o, e, { enumerable: !0, configurable: !0, writable: !0, value: t }) : o[e] = t;
3
+ var i = (o, e, t) => m(o, typeof e != "symbol" ? e + "" : e, t);
4
+ class C {
5
+ constructor(e) {
6
+ i(this, "startTime", 0);
7
+ i(this, "websocket", null);
8
+ i(this, "outputGeneration", 0);
9
+ i(this, "unsubscribeOutput", null);
10
+ i(this, "settleStart", null);
11
+ this.initConfig = e;
12
+ }
13
+ // Resolves when the WebSocket opens and the asciicast header is sent.
14
+ // Rejects with an error string on any failure.
15
+ start() {
16
+ this.internalStop();
17
+ const { cols: e, rows: t, env: r, terminal: s } = this.initConfig, c = new URL(this.initConfig.wsUrl);
18
+ c.searchParams.set("fileType", "asciicast"), this.startTime = Date.now();
19
+ const d = ++this.outputGeneration;
20
+ let l, S = !1;
21
+ const u = new Promise((n, a) => {
22
+ l = (f) => {
23
+ S || (S = !0, this.settleStart = null, f !== void 0 ? a(f) : n());
24
+ };
25
+ });
26
+ this.settleStart = l;
27
+ let p = !1, h = null;
28
+ const R = () => {
29
+ !h || this.websocket !== h || (this.outputGeneration++, this.unsubscribeOutput && (this.unsubscribeOutput(), this.unsubscribeOutput = null), this.websocket = null, l(p ? "ConnectionToTheRecordingServerLost" : "UnableToConnectToTheRecordingServer"));
30
+ };
31
+ try {
32
+ h = new b(c.toString(), {
33
+ onOpen: () => {
34
+ this.websocket === h && (p = !0, l());
35
+ },
36
+ onError: R,
37
+ onClose: R
38
+ });
39
+ } catch (n) {
40
+ return console.error("[AsciiCastV2Recorder] Failed to create WebSocket:", n), l("UnableToConnectToTheRecordingServer"), u;
41
+ }
42
+ this.websocket = h, this.send({
43
+ version: 2,
44
+ timestamp: Math.floor(this.startTime / 1e3),
45
+ width: e,
46
+ height: t,
47
+ env: r
48
+ });
49
+ try {
50
+ this.unsubscribeOutput = s.onServerOutput((n) => {
51
+ if (this.outputGeneration !== d || !this.websocket)
52
+ return;
53
+ const a = n.replace(/\r?\n/g, `\r
54
+ `);
55
+ this.onEvent("o", a);
56
+ });
57
+ } catch (n) {
58
+ console.error("[AsciiCastV2Recorder] Failed to subscribe to terminal output:", n);
59
+ const a = this.websocket;
60
+ return this.websocket = null, a == null || a.close(), l("UnableToStartRecording"), u;
61
+ }
62
+ return u;
63
+ }
64
+ stop() {
65
+ this.internalStop();
66
+ }
67
+ internalStop() {
68
+ var t;
69
+ this.outputGeneration++, this.unsubscribeOutput && (this.unsubscribeOutput(), this.unsubscribeOutput = null), (t = this.settleStart) == null || t.call(this), this.settleStart = null;
70
+ const e = this.websocket;
71
+ this.websocket = null, e == null || e.close();
72
+ }
73
+ onEvent(e, t) {
74
+ const r = (Date.now() - this.startTime) / 1e3;
75
+ this.send([r, e, t]);
76
+ }
77
+ send(e) {
78
+ var t;
79
+ (t = this.websocket) == null || t.send(JSON.stringify(e) + `
80
+ `);
81
+ }
82
+ }
83
+ class b {
84
+ constructor(e, t) {
85
+ i(this, "ws");
86
+ i(this, "queue", []);
87
+ i(this, "ready", !1);
88
+ this.ws = new WebSocket(e), this.ws.onopen = () => {
89
+ var r;
90
+ this.ready = !0;
91
+ for (const s of this.queue)
92
+ this.ws.send(s);
93
+ this.queue = [], (r = t == null ? void 0 : t.onOpen) == null || r.call(t);
94
+ }, this.ws.onclose = () => {
95
+ var r;
96
+ this.ready = !1, this.queue = [], (r = t == null ? void 0 : t.onClose) == null || r.call(t);
97
+ }, this.ws.onerror = () => {
98
+ var r;
99
+ (r = t == null ? void 0 : t.onError) == null || r.call(t);
100
+ };
101
+ }
102
+ send(e) {
103
+ this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED || (this.ready ? this.ws.send(e) : this.queue.push(e));
104
+ }
105
+ close() {
106
+ this.ready = !1, this.queue = [], this.ws.readyState !== WebSocket.CLOSING && this.ws.readyState !== WebSocket.CLOSED && this.ws.close();
107
+ }
108
+ }
109
+ const w = 8, v = 10;
110
+ class W {
111
+ constructor(e = {}) {
112
+ i(this, "mediaRecorder", null);
113
+ i(this, "ws", null);
114
+ i(this, "resolveStart", null);
115
+ i(this, "rejectStart", null);
116
+ i(this, "_isRecording", !1);
117
+ i(this, "stream", null);
118
+ i(this, "canvas", null);
119
+ i(this, "keepaliveRaf", null);
120
+ i(this, "keepaliveTick", !1);
121
+ i(this, "isStarting", !1);
122
+ i(this, "isCleaningUp", !1);
123
+ i(this, "pendingError", null);
124
+ this.options = e;
125
+ }
126
+ get isRecording() {
127
+ return this._isRecording;
128
+ }
129
+ // Resolves when the first data chunk is received (recording confirmed active).
130
+ // Rejects with an error string on any failure.
131
+ start(e, t) {
132
+ var s;
133
+ if (this._isRecording || this.isStarting || this.isCleaningUp)
134
+ return console.warn("[WebMRecorder] Recording already in progress or cleaning up"), Promise.reject("RecordingAlreadyInProgress");
135
+ const r = new Promise((c, d) => {
136
+ this.resolveStart = c, this.rejectStart = d;
137
+ });
138
+ return this.isStarting = !0, this.canvas = e, this.initializeCapture(e) ? (this.startStreaming(t), r) : (this.isStarting = !1, (s = this.rejectStart) == null || s.call(this, "UnableToStartRecording"), this.resolveStart = null, this.rejectStart = null, r);
139
+ }
140
+ stop() {
141
+ var e;
142
+ if (!this.isCleaningUp) {
143
+ if (this.isCleaningUp = !0, this.stopKeepalive(), this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
144
+ this.mediaRecorder.stop();
145
+ return;
146
+ }
147
+ (e = this.resolveStart) == null || e.call(this), this.resolveStart = null, this.rejectStart = null, this.closeWebSocket(), this.cleanupResources(), this.fireTelemetry("recording-stopped");
148
+ }
149
+ }
150
+ // Initialize canvas capture stream
151
+ initializeCapture(e) {
152
+ if (!e)
153
+ return console.error("[WebMRecorder] Canvas element is null"), !1;
154
+ try {
155
+ this.stream = e.captureStream(w);
156
+ } catch (t) {
157
+ return console.error("Failed to initialize canvas capture:", t), !1;
158
+ }
159
+ return this.fireTelemetry("recording-initialized"), !0;
160
+ }
161
+ startStreaming(e) {
162
+ var t, r;
163
+ if (!this.stream) {
164
+ console.error("No capture stream initialized"), this.isStarting = !1, (t = this.rejectStart) == null || t.call(this, "UnableToStartRecording"), this.resolveStart = null, this.rejectStart = null;
165
+ return;
166
+ }
167
+ try {
168
+ this.initializeWebSocket(e);
169
+ } catch (s) {
170
+ console.error("[WebMRecorder] Failed to create WebSocket:", s), this.isStarting = !1, (r = this.rejectStart) == null || r.call(this, "UnableToConnectToTheRecordingServer"), this.resolveStart = null, this.rejectStart = null, this.cleanupResources();
171
+ }
172
+ }
173
+ initializeWebSocket(e) {
174
+ const t = e.includes("?") ? "&" : "?";
175
+ this.ws = new WebSocket(`${e}${t}fileType=webm`), this.ws.onopen = this.handleWebSocketOpen.bind(this), this.ws.onerror = this.handleWebSocketError.bind(this), this.ws.onclose = this.handleWebSocketClose.bind(this);
176
+ }
177
+ handleWebSocketOpen() {
178
+ var e, t;
179
+ if (!this.stream) {
180
+ this.isStarting = !1, (e = this.rejectStart) == null || e.call(this, "UnableToStartRecording"), this.resolveStart = null, this.rejectStart = null, this.closeWebSocket(), this.cleanupResources();
181
+ return;
182
+ }
183
+ try {
184
+ const r = new MediaRecorder(this.stream, { mimeType: "video/webm" });
185
+ this.mediaRecorder = r, r.onstart = this.handleMediaRecorderStart.bind(this), r.ondataavailable = this.handleMediaRecorderDataAvailable.bind(this), r.onstop = this.handleMediaRecorderStop.bind(this), r.onerror = this.handleMediaRecorderError.bind(this), r.start(v);
186
+ } catch (r) {
187
+ console.error("[WebMRecorder] Failed to start MediaRecorder:", r), this.isStarting = !1, (t = this.rejectStart) == null || t.call(this, "UnableToStartRecording"), this.resolveStart = null, this.rejectStart = null, this.closeWebSocket(), this.cleanupResources();
188
+ }
189
+ }
190
+ handleWebSocketClose(e) {
191
+ var s;
192
+ if (this.isCleaningUp)
193
+ return;
194
+ this.isCleaningUp = !0, this.stopKeepalive();
195
+ const r = this._isRecording || this.mediaRecorder !== null && this.mediaRecorder.state !== "inactive" ? "ConnectionToTheRecordingServerLost" : "UnableToConnectToTheRecordingServer";
196
+ if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
197
+ this.pendingError = r, this.mediaRecorder.stop();
198
+ return;
199
+ }
200
+ console.warn("[WebMRecorder] WebSocket closed unexpectedly (no active recorder):", e), (s = this.rejectStart) == null || s.call(this, r), this.resolveStart = null, this.rejectStart = null, this.cleanupResources();
201
+ }
202
+ handleWebSocketError(e) {
203
+ var s;
204
+ if (console.error("[WebMRecorder] WebSocket error:", e), this.isCleaningUp) return;
205
+ this.isCleaningUp = !0, this.stopKeepalive();
206
+ const r = this._isRecording || this.mediaRecorder !== null && this.mediaRecorder.state !== "inactive" ? "ConnectionToTheRecordingServerLost" : "UnableToConnectToTheRecordingServer";
207
+ if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
208
+ this.pendingError = r, this.mediaRecorder.stop();
209
+ return;
210
+ }
211
+ (s = this.rejectStart) == null || s.call(this, r), this.resolveStart = null, this.rejectStart = null, this.closeWebSocket(), this.cleanupResources();
212
+ }
213
+ // Browser captureStream implementations are unreliable for static or sparsely updated canvases.
214
+ // Drive the keepalive from rAF so the nudge is aligned with the rendering pipeline, and make a real
215
+ // change each frame so captureStream always has fresh canvas content to sample.
216
+ handleMediaRecorderStart() {
217
+ const e = () => {
218
+ this.nudgeCanvas(), this.keepaliveRaf = requestAnimationFrame(e);
219
+ };
220
+ this.keepaliveRaf = requestAnimationFrame(e);
221
+ }
222
+ // Nudge a single corner pixel with a *real* value change. A zero-alpha / no-op draw is elided by
223
+ // some engines' dirty-tracking (Edge 149 → empty WebM), so we alternate the pixel value. captureStream
224
+ // throttles the actual capture to CANVAS_STREAM_FPS regardless of the rAF rate. save()/restore() keeps
225
+ // this from leaking state onto the canvas's shared 2D context.
226
+ nudgeCanvas() {
227
+ var t;
228
+ const e = (t = this.canvas) == null ? void 0 : t.getContext("2d");
229
+ e && (e.save(), e.globalAlpha = 1, e.fillStyle = this.keepaliveTick ? "#000000" : "#000001", e.fillRect(0, 0, 1, 1), e.restore(), this.keepaliveTick = !this.keepaliveTick);
230
+ }
231
+ handleMediaRecorderDataAvailable(e) {
232
+ var t;
233
+ !e.data || e.data.size === 0 || (this._isRecording || (this.isStarting = !1, this._isRecording = !0, (t = this.resolveStart) == null || t.call(this), this.resolveStart = null, this.rejectStart = null), this.ws && this.ws.readyState === WebSocket.OPEN && this.ws.send(e.data));
234
+ }
235
+ handleMediaRecorderStop() {
236
+ var t, r;
237
+ this.closeWebSocket();
238
+ const e = this.pendingError;
239
+ e ? (t = this.rejectStart) == null || t.call(this, e) : (r = this.resolveStart) == null || r.call(this), this.resolveStart = null, this.rejectStart = null, this.cleanupResources(), e || this.fireTelemetry("recording-stopped");
240
+ }
241
+ handleMediaRecorderError(e) {
242
+ console.error("[WebMRecorder] MediaRecorder encountered an error:", e), !this.isCleaningUp && (this.isCleaningUp = !0, this.stopKeepalive(), this.pendingError = "UnableToStartRecording", this.mediaRecorder && this.mediaRecorder.state !== "inactive" && this.mediaRecorder.stop());
243
+ }
244
+ fireTelemetry(e) {
245
+ var t, r;
246
+ try {
247
+ (r = (t = this.options).onTelemetry) == null || r.call(t, e);
248
+ } catch (s) {
249
+ console.warn("[WebMRecorder] onTelemetry hook threw:", s);
250
+ }
251
+ }
252
+ cleanupResources() {
253
+ this._isRecording = !1, this.isStarting = !1, this.stopKeepalive(), this.ws && (this.ws.onopen = null, this.ws.onerror = null, this.ws.onclose = null), this.stream && (this.stream.getTracks().forEach((e) => e.stop()), this.stream = null), this.canvas = null, 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 = !1;
254
+ }
255
+ stopKeepalive() {
256
+ this.keepaliveRaf !== null && (cancelAnimationFrame(this.keepaliveRaf), this.keepaliveRaf = null);
257
+ }
258
+ closeWebSocket() {
259
+ this.ws && (this.ws.onclose = null, this.ws.onerror = null, (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) && this.ws.close());
260
+ }
261
+ }
262
+ export {
263
+ C as AsciiCastV2Recorder,
264
+ W as WebMRecorder
265
+ };
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@devolutions/web-recorder",
3
+ "version": "0.1.0",
4
+ "description": "Framework-agnostic browser session-recording capture (WebM video + asciicast terminal) for Devolutions Gateway jrec push",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "module": "./index.js",
8
+ "types": "./index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./index.js",
12
+ "types": "./index.d.ts"
13
+ }
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/Devolutions/devolutions-gateway.git"
18
+ },
19
+ "keywords": [
20
+ "recording",
21
+ "session-recording",
22
+ "webm",
23
+ "asciicast",
24
+ "capture",
25
+ "media-recorder"
26
+ ],
27
+ "license": "MIT OR Apache-2.0",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ }
31
+ }
@@ -0,0 +1,3 @@
1
+ export interface IRecordableSession {
2
+ shouldStartRecording: boolean;
3
+ }
@@ -0,0 +1,38 @@
1
+ export type WebMRecorderTelemetryEvent = 'recording-initialized' | 'recording-stopped';
2
+ export interface WebMRecorderOptions {
3
+ onTelemetry?: (event: WebMRecorderTelemetryEvent) => void;
4
+ }
5
+ export declare class WebMRecorder {
6
+ private readonly options;
7
+ private mediaRecorder;
8
+ private ws;
9
+ private resolveStart;
10
+ private rejectStart;
11
+ private _isRecording;
12
+ private stream;
13
+ private canvas;
14
+ private keepaliveRaf;
15
+ private keepaliveTick;
16
+ private isStarting;
17
+ private isCleaningUp;
18
+ private pendingError;
19
+ constructor(options?: WebMRecorderOptions);
20
+ get isRecording(): boolean;
21
+ start(canvas: HTMLCanvasElement, recordingUrl: string): Promise<void>;
22
+ stop(): void;
23
+ private initializeCapture;
24
+ private startStreaming;
25
+ private initializeWebSocket;
26
+ private handleWebSocketOpen;
27
+ private handleWebSocketClose;
28
+ private handleWebSocketError;
29
+ private handleMediaRecorderStart;
30
+ private nudgeCanvas;
31
+ private handleMediaRecorderDataAvailable;
32
+ private handleMediaRecorderStop;
33
+ private handleMediaRecorderError;
34
+ private fireTelemetry;
35
+ private cleanupResources;
36
+ private stopKeepalive;
37
+ private closeWebSocket;
38
+ }