deckide 3.5.41 → 3.5.43

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,493 @@
1
+ import { spawn, execFile } from 'node:child_process';
2
+ import dgram from 'node:dgram';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { WebSocket } from 'ws';
7
+ import { AGENT_BROWSER_WEBRTC, AGENT_BROWSER_WEBRTC_CODEC, AGENT_BROWSER_WEBRTC_BITRATE, AGENT_BROWSER_WEBRTC_FPS, AGENT_BROWSER_DISPLAY, AGENT_BROWSER_WEBRTC_ICE_SERVERS, AGENT_BROWSER_CDP_PORT, } from '../config.js';
8
+ const execFileAsync = promisify(execFile);
9
+ let weriftPromise = null;
10
+ function loadWerift() {
11
+ if (!weriftPromise) {
12
+ weriftPromise = import('werift').then((m) => m).catch((error) => {
13
+ console.error('[browser-webrtc] werift unavailable:', error instanceof Error ? error.message : error);
14
+ return null;
15
+ });
16
+ }
17
+ return weriftPromise;
18
+ }
19
+ const DEFAULT_SIZE = { width: 1280, height: 720 };
20
+ async function pathExists(filePath) {
21
+ try {
22
+ await fs.access(filePath);
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ async function findInPath(names) {
30
+ const pathValue = process.env.PATH || '';
31
+ for (const dir of pathValue.split(path.delimiter)) {
32
+ if (!dir)
33
+ continue;
34
+ for (const name of names) {
35
+ const candidate = path.join(dir, name);
36
+ if (await pathExists(candidate)) {
37
+ return candidate;
38
+ }
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+ async function resolveExecutable(envName, names) {
44
+ const configured = process.env[envName];
45
+ if (configured) {
46
+ return (await pathExists(configured)) ? configured : null;
47
+ }
48
+ return findInPath(names);
49
+ }
50
+ // Discover the X display + Xauthority that Chrome is actually using. xvfb-run
51
+ // picks the display number for the Chrome child and exports DISPLAY/XAUTHORITY
52
+ // only into that child's environment, so the server process can't read them from
53
+ // its own env — we recover them from /proc/<pid>/environ instead.
54
+ async function resolveXEnv(cdpPort) {
55
+ if (AGENT_BROWSER_DISPLAY) {
56
+ return { display: AGENT_BROWSER_DISPLAY, xauthority: process.env.XAUTHORITY ?? null };
57
+ }
58
+ let pids;
59
+ try {
60
+ pids = (await fs.readdir('/proc')).filter((name) => /^\d+$/.test(name));
61
+ }
62
+ catch {
63
+ // Not Linux / no procfs.
64
+ return process.env.DISPLAY ? { display: process.env.DISPLAY, xauthority: process.env.XAUTHORITY ?? null } : null;
65
+ }
66
+ const portToken = cdpPort ? `--remote-debugging-port=${cdpPort}` : null;
67
+ let fallback = null;
68
+ for (const pid of pids) {
69
+ let comm = '';
70
+ try {
71
+ comm = await fs.readFile(`/proc/${pid}/comm`, 'utf8');
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ if (!/chrome|chromium/i.test(comm)) {
77
+ continue;
78
+ }
79
+ let environ;
80
+ try {
81
+ environ = await fs.readFile(`/proc/${pid}/environ`, 'utf8');
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ const vars = environ.split('\0');
87
+ const display = vars.find((v) => v.startsWith('DISPLAY='))?.slice('DISPLAY='.length);
88
+ if (!display) {
89
+ continue;
90
+ }
91
+ const xauthority = vars.find((v) => v.startsWith('XAUTHORITY='))?.slice('XAUTHORITY='.length) ?? null;
92
+ const candidate = { display, xauthority };
93
+ if (!portToken) {
94
+ return candidate;
95
+ }
96
+ // With multiple Chrome instances, prefer the one bound to OUR CDP port so we
97
+ // capture (and control) the same browser. Fall back to the first found.
98
+ let cmdline = '';
99
+ try {
100
+ cmdline = (await fs.readFile(`/proc/${pid}/cmdline`, 'utf8')).replace(/\0/g, ' ');
101
+ }
102
+ catch {
103
+ // ignore
104
+ }
105
+ if (cmdline.includes(portToken)) {
106
+ return candidate;
107
+ }
108
+ if (!fallback) {
109
+ fallback = candidate;
110
+ }
111
+ }
112
+ return fallback ?? (process.env.DISPLAY ? { display: process.env.DISPLAY, xauthority: process.env.XAUTHORITY ?? null } : null);
113
+ }
114
+ // Query the real screen geometry of the display so x11grab captures the exact
115
+ // region (a mismatch makes ffmpeg error or grab garbage).
116
+ async function resolveScreenSize(env) {
117
+ try {
118
+ const { stdout } = await execFileAsync('xdpyinfo', [], { env, timeout: 2000, maxBuffer: 256 * 1024 });
119
+ const match = stdout.match(/dimensions:\s+(\d+)x(\d+)/);
120
+ if (match) {
121
+ return { width: Number(match[1]), height: Number(match[2]) };
122
+ }
123
+ }
124
+ catch {
125
+ // xdpyinfo missing or failed — fall back to the configured Xvfb size.
126
+ }
127
+ return { ...DEFAULT_SIZE };
128
+ }
129
+ function parseIceServers() {
130
+ if (!AGENT_BROWSER_WEBRTC_ICE_SERVERS.trim()) {
131
+ return [];
132
+ }
133
+ try {
134
+ const parsed = JSON.parse(AGENT_BROWSER_WEBRTC_ICE_SERVERS);
135
+ return Array.isArray(parsed) ? parsed : [];
136
+ }
137
+ catch {
138
+ return [];
139
+ }
140
+ }
141
+ function isOpen(socket) {
142
+ return socket.readyState === WebSocket.OPEN;
143
+ }
144
+ export class BrowserWebRtcRelay {
145
+ host;
146
+ viewers = new Map();
147
+ // Every connected viewer's video track; the single encoder's RTP is fanned out
148
+ // to all of them (one encoder, N peers).
149
+ tracks = new Set();
150
+ encoder = null;
151
+ udp = null;
152
+ startingEncoder = null;
153
+ display = null;
154
+ size = { ...DEFAULT_SIZE };
155
+ lastError;
156
+ constructor(host) {
157
+ this.host = host;
158
+ }
159
+ async getStatus() {
160
+ let available = AGENT_BROWSER_WEBRTC;
161
+ let reason;
162
+ if (!AGENT_BROWSER_WEBRTC) {
163
+ reason = 'WebRTC streaming is disabled (set AGENT_BROWSER_WEBRTC=true)';
164
+ }
165
+ else {
166
+ const werift = await loadWerift();
167
+ const ffmpeg = await resolveExecutable('AGENT_BROWSER_FFMPEG', ['ffmpeg']);
168
+ const xenv = await resolveXEnv(AGENT_BROWSER_CDP_PORT);
169
+ if (!werift) {
170
+ available = false;
171
+ reason = 'werift is not installed (npm install werift)';
172
+ }
173
+ else if (!ffmpeg) {
174
+ available = false;
175
+ reason = 'ffmpeg is not installed or AGENT_BROWSER_FFMPEG is not set';
176
+ }
177
+ else if (!xenv) {
178
+ available = false;
179
+ reason = 'No X display found to capture (WebRTC needs an Xvfb/headful display)';
180
+ }
181
+ }
182
+ return {
183
+ available,
184
+ running: Boolean(this.encoder),
185
+ clients: this.viewers.size,
186
+ codec: AGENT_BROWSER_WEBRTC_CODEC,
187
+ display: this.display,
188
+ width: this.size.width,
189
+ height: this.size.height,
190
+ reason,
191
+ error: this.lastError,
192
+ };
193
+ }
194
+ attachWebSocket(socket) {
195
+ socket.on('message', (data, isBinary) => {
196
+ if (isBinary)
197
+ return;
198
+ void this.handleSignal(socket, data.toString());
199
+ });
200
+ socket.on('close', () => this.removeViewer(socket));
201
+ socket.on('error', () => this.removeViewer(socket));
202
+ void this.sendStatus(socket);
203
+ }
204
+ async stop() {
205
+ for (const socket of [...this.viewers.keys()]) {
206
+ this.removeViewer(socket);
207
+ }
208
+ await this.stopEncoder();
209
+ }
210
+ async handleSignal(socket, raw) {
211
+ let message;
212
+ try {
213
+ message = JSON.parse(raw);
214
+ }
215
+ catch {
216
+ return;
217
+ }
218
+ try {
219
+ if (message.type === 'webrtc-offer' && typeof message.sdp === 'string') {
220
+ await this.handleOffer(socket, message.sdp);
221
+ return;
222
+ }
223
+ if (message.type === 'webrtc-ice' && message.candidate) {
224
+ const viewer = this.viewers.get(socket);
225
+ if (viewer) {
226
+ await viewer.pc.addIceCandidate(message.candidate).catch(() => undefined);
227
+ }
228
+ }
229
+ }
230
+ catch (error) {
231
+ const detail = error instanceof Error ? error.message : String(error);
232
+ this.lastError = detail;
233
+ this.send(socket, { type: 'webrtc-error', error: detail });
234
+ }
235
+ }
236
+ async handleOffer(socket, sdp) {
237
+ const werift = await loadWerift();
238
+ if (!werift) {
239
+ throw new Error('werift is not installed');
240
+ }
241
+ if (!this.host.isBrowserRunning()) {
242
+ throw new Error('Browser is not running');
243
+ }
244
+ // Tear down any prior peer on this socket (renegotiation / reconnect).
245
+ this.removeViewer(socket, { keepSocket: true });
246
+ const { RTCPeerConnection, MediaStreamTrack, RTCRtpCodecParameters } = werift;
247
+ const pc = new RTCPeerConnection({
248
+ codecs: {
249
+ video: [
250
+ new RTCRtpCodecParameters({
251
+ mimeType: 'video/VP8',
252
+ clockRate: 90000,
253
+ payloadType: 96,
254
+ }),
255
+ ],
256
+ },
257
+ iceServers: parseIceServers(),
258
+ });
259
+ const track = new MediaStreamTrack({ kind: 'video' });
260
+ pc.addTransceiver(track, { direction: 'sendonly' });
261
+ const viewer = { socket, pc, track };
262
+ this.viewers.set(socket, viewer);
263
+ this.tracks.add(track);
264
+ pc.connectionStateChange.subscribe((state) => {
265
+ if (state === 'failed' || state === 'closed' || state === 'disconnected') {
266
+ this.removeViewer(socket);
267
+ }
268
+ });
269
+ await pc.setRemoteDescription({ type: 'offer', sdp });
270
+ const answer = await pc.createAnswer();
271
+ await pc.setLocalDescription(answer);
272
+ await this.waitIceComplete(pc);
273
+ // Bring up the encoder once we actually have a consumer.
274
+ await this.ensureEncoder();
275
+ if (!isOpen(socket)) {
276
+ this.removeViewer(socket);
277
+ return;
278
+ }
279
+ this.send(socket, { type: 'webrtc-answer', sdp: pc.localDescription.sdp });
280
+ await this.broadcastStatus();
281
+ }
282
+ // Non-trickle ICE: wait for host candidates to gather (instant on same-host /
283
+ // LAN), then send a complete answer. Simpler and robust for the common case;
284
+ // trickle can be layered on later for remote/NAT.
285
+ waitIceComplete(pc) {
286
+ if (pc.iceGatheringState === 'complete') {
287
+ return Promise.resolve();
288
+ }
289
+ return new Promise((resolve) => {
290
+ const timer = setTimeout(resolve, 2000);
291
+ timer.unref?.();
292
+ pc.iceGatheringStateChange.subscribe((state) => {
293
+ if (state === 'complete') {
294
+ clearTimeout(timer);
295
+ resolve();
296
+ }
297
+ });
298
+ });
299
+ }
300
+ async ensureEncoder() {
301
+ if (this.encoder) {
302
+ return;
303
+ }
304
+ if (this.startingEncoder) {
305
+ return this.startingEncoder;
306
+ }
307
+ this.startingEncoder = this.startEncoder();
308
+ try {
309
+ await this.startingEncoder;
310
+ }
311
+ finally {
312
+ this.startingEncoder = null;
313
+ }
314
+ }
315
+ async startEncoder() {
316
+ this.lastError = undefined;
317
+ const ffmpeg = await resolveExecutable('AGENT_BROWSER_FFMPEG', ['ffmpeg']);
318
+ if (!ffmpeg) {
319
+ throw new Error('ffmpeg is required for WebRTC streaming');
320
+ }
321
+ const xenv = await resolveXEnv(AGENT_BROWSER_CDP_PORT);
322
+ if (!xenv) {
323
+ throw new Error('No X display found to capture');
324
+ }
325
+ this.display = xenv.display;
326
+ const captureEnv = { ...process.env, DISPLAY: xenv.display };
327
+ if (xenv.xauthority) {
328
+ captureEnv.XAUTHORITY = xenv.xauthority;
329
+ }
330
+ this.size = await resolveScreenSize(captureEnv);
331
+ // Size the page's OS window to fill the captured screen so the framebuffer
332
+ // region maps 1:1 to page coordinates. Best-effort; ignore failures.
333
+ await this.host.setActiveWindowBounds(0, 0, this.size.width, this.size.height).catch(() => undefined);
334
+ // Pin the page render size to the framebuffer so input coords map 1:1.
335
+ await this.host.pinViewport(this.size.width, this.size.height).catch(() => undefined);
336
+ // Bind an ephemeral UDP port; ffmpeg sends RTP here and we fan each packet
337
+ // out to every viewer's track (no re-encode in werift).
338
+ const udp = dgram.createSocket('udp4');
339
+ await new Promise((resolve, reject) => {
340
+ udp.once('error', reject);
341
+ udp.bind(0, '127.0.0.1', () => {
342
+ udp.off('error', reject);
343
+ resolve();
344
+ });
345
+ });
346
+ this.udp = udp;
347
+ const port = udp.address().port;
348
+ udp.on('message', (packet) => {
349
+ for (const track of this.tracks) {
350
+ try {
351
+ track.writeRtp(packet);
352
+ }
353
+ catch {
354
+ // A dead track will be cleaned up on its peer's close event.
355
+ }
356
+ }
357
+ });
358
+ const fps = Math.max(1, AGENT_BROWSER_WEBRTC_FPS);
359
+ const args = [
360
+ '-hide_banner',
361
+ '-loglevel', 'warning',
362
+ '-nostdin',
363
+ '-f', 'x11grab',
364
+ '-draw_mouse', '0',
365
+ '-framerate', String(fps),
366
+ '-video_size', `${this.size.width}x${this.size.height}`,
367
+ '-i', `${xenv.display}+0,0`,
368
+ '-pix_fmt', 'yuv420p',
369
+ '-c:v', 'libvpx',
370
+ '-deadline', 'realtime',
371
+ '-cpu-used', '8',
372
+ '-b:v', AGENT_BROWSER_WEBRTC_BITRATE,
373
+ '-maxrate', AGENT_BROWSER_WEBRTC_BITRATE,
374
+ '-bufsize', AGENT_BROWSER_WEBRTC_BITRATE,
375
+ '-g', String(fps * 2),
376
+ '-error-resilient', '1',
377
+ '-auto-alt-ref', '0',
378
+ '-an',
379
+ '-payload_type', '96',
380
+ '-ssrc', '1',
381
+ '-f', 'rtp',
382
+ `rtp://127.0.0.1:${port}`,
383
+ ];
384
+ const proc = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'pipe'], env: captureEnv });
385
+ this.encoder = proc;
386
+ let stderrTail = '';
387
+ proc.stderr.setEncoding('utf8');
388
+ proc.stderr.on('data', (chunk) => {
389
+ stderrTail = (stderrTail + chunk).slice(-2000);
390
+ });
391
+ proc.once('exit', (code, signal) => {
392
+ if (this.encoder === proc) {
393
+ this.encoder = null;
394
+ this.closeUdp();
395
+ if (this.viewers.size > 0) {
396
+ this.lastError = `WebRTC encoder exited (${signal ?? code ?? 'unknown'}): ${stderrTail.trim()}`;
397
+ void this.broadcastStatus();
398
+ }
399
+ }
400
+ });
401
+ proc.once('error', (error) => {
402
+ if (this.encoder === proc) {
403
+ this.encoder = null;
404
+ this.closeUdp();
405
+ }
406
+ this.lastError = error.message;
407
+ void this.broadcastStatus();
408
+ });
409
+ await this.broadcastStatus();
410
+ }
411
+ async stopEncoder() {
412
+ const proc = this.encoder;
413
+ this.encoder = null;
414
+ if (proc && proc.exitCode == null && proc.signalCode == null) {
415
+ await new Promise((resolve) => {
416
+ const killTimer = setTimeout(() => {
417
+ try {
418
+ proc.kill('SIGKILL');
419
+ }
420
+ catch { /* ignore */ }
421
+ }, 2000);
422
+ killTimer.unref?.();
423
+ proc.once('exit', () => { clearTimeout(killTimer); resolve(); });
424
+ try {
425
+ proc.kill('SIGTERM');
426
+ }
427
+ catch {
428
+ clearTimeout(killTimer);
429
+ resolve();
430
+ }
431
+ });
432
+ }
433
+ this.closeUdp();
434
+ }
435
+ closeUdp() {
436
+ const udp = this.udp;
437
+ this.udp = null;
438
+ if (udp) {
439
+ try {
440
+ udp.close();
441
+ }
442
+ catch { /* ignore */ }
443
+ }
444
+ }
445
+ removeViewer(socket, options = {}) {
446
+ const viewer = this.viewers.get(socket);
447
+ if (viewer) {
448
+ this.viewers.delete(socket);
449
+ this.tracks.delete(viewer.track);
450
+ try {
451
+ viewer.track.stop();
452
+ }
453
+ catch { /* ignore */ }
454
+ try {
455
+ void viewer.pc.close();
456
+ }
457
+ catch { /* ignore */ }
458
+ }
459
+ if (!options.keepSocket && isOpen(socket)) {
460
+ try {
461
+ socket.close();
462
+ }
463
+ catch { /* ignore */ }
464
+ }
465
+ // Last viewer gone → stop the shared encoder.
466
+ if (this.viewers.size === 0) {
467
+ void this.stopEncoder();
468
+ }
469
+ }
470
+ async sendStatus(socket) {
471
+ this.send(socket, { type: 'webrtc-status', status: await this.getStatus() });
472
+ }
473
+ async broadcastStatus() {
474
+ const status = await this.getStatus();
475
+ for (const socket of this.viewers.keys()) {
476
+ this.send(socket, { type: 'webrtc-status', status });
477
+ }
478
+ }
479
+ send(socket, payload) {
480
+ if (!isOpen(socket)) {
481
+ return;
482
+ }
483
+ try {
484
+ socket.send(JSON.stringify(payload));
485
+ }
486
+ catch {
487
+ try {
488
+ socket.close();
489
+ }
490
+ catch { /* ignore */ }
491
+ }
492
+ }
493
+ }