sessioncast-cli 2.3.0 → 2.3.1
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.
- package/dist/agent/browser-handler.d.ts +100 -0
- package/dist/agent/browser-handler.js +485 -0
- package/dist/agent/cdp-client.d.ts +28 -0
- package/dist/agent/cdp-client.js +115 -0
- package/dist/agent/chrome-finder.d.ts +5 -0
- package/dist/agent/chrome-finder.js +100 -0
- package/dist/agent/crypto.d.ts +2 -0
- package/dist/agent/crypto.js +24 -0
- package/dist/agent/runner.d.ts +7 -1
- package/dist/agent/runner.js +60 -2
- package/dist/agent/session-handler.js +2 -1
- package/dist/agent/tunnel-handler.d.ts +12 -0
- package/dist/agent/tunnel-handler.js +180 -0
- package/dist/agent/types.d.ts +21 -0
- package/dist/agent/websocket.d.ts +18 -0
- package/dist/agent/websocket.js +113 -15
- package/dist/commands/agent.js +1 -1
- package/dist/commands/tunnel.d.ts +10 -0
- package/dist/commands/tunnel.js +201 -0
- package/dist/index.js +23 -0
- package/package.json +3 -2
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export interface BrowserStartOptions {
|
|
2
|
+
url?: string;
|
|
3
|
+
width?: number;
|
|
4
|
+
height?: number;
|
|
5
|
+
chromePath?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface DomEvent {
|
|
8
|
+
type: number;
|
|
9
|
+
data: any;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
}
|
|
12
|
+
type DomEventCallback = (event: DomEvent) => void;
|
|
13
|
+
/**
|
|
14
|
+
* Manages a headless Chrome instance with rrweb-based DOM recording.
|
|
15
|
+
* Spawns Chrome with --headless=new, connects via CDP, injects rrweb,
|
|
16
|
+
* and streams DOM mutation events through a callback.
|
|
17
|
+
*/
|
|
18
|
+
export declare class BrowserHandler {
|
|
19
|
+
private chrome;
|
|
20
|
+
private cdp;
|
|
21
|
+
private domCallback;
|
|
22
|
+
private eventBuffer;
|
|
23
|
+
private running;
|
|
24
|
+
/**
|
|
25
|
+
* Launch headless Chrome and connect CDP.
|
|
26
|
+
* @param port Remote debugging port for Chrome
|
|
27
|
+
* @param opts Optional browser configuration
|
|
28
|
+
*/
|
|
29
|
+
start(port: number, opts?: BrowserStartOptions): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Register a callback for rrweb DOM events.
|
|
32
|
+
* Flushes any events buffered before the callback was registered.
|
|
33
|
+
*/
|
|
34
|
+
onDomEvent(cb: DomEventCallback): void;
|
|
35
|
+
/**
|
|
36
|
+
* Dispatch an input event to the page via CDP.
|
|
37
|
+
* Accepts WebInputEvent from the web client (inputType field).
|
|
38
|
+
*/
|
|
39
|
+
dispatchInput(event: {
|
|
40
|
+
inputType?: string;
|
|
41
|
+
type?: string;
|
|
42
|
+
x?: number;
|
|
43
|
+
y?: number;
|
|
44
|
+
button?: string;
|
|
45
|
+
clickCount?: number;
|
|
46
|
+
key?: string;
|
|
47
|
+
code?: string;
|
|
48
|
+
text?: string;
|
|
49
|
+
modifiers?: number;
|
|
50
|
+
deltaX?: number;
|
|
51
|
+
deltaY?: number;
|
|
52
|
+
}): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Trigger rrweb to emit a fresh FullSnapshot by restarting the recording.
|
|
55
|
+
* Stops the current recording and starts a new one, which immediately
|
|
56
|
+
* produces a FullSnapshot of the current DOM state.
|
|
57
|
+
*/
|
|
58
|
+
takeFullSnapshot(): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Navigate the browser to a new URL.
|
|
61
|
+
* Only localhost/127.0.0.1 URLs are allowed for security.
|
|
62
|
+
*/
|
|
63
|
+
navigate(url: string): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Stop Chrome and close the CDP connection.
|
|
66
|
+
*/
|
|
67
|
+
stop(): void;
|
|
68
|
+
/**
|
|
69
|
+
* Check whether the browser is currently running.
|
|
70
|
+
*/
|
|
71
|
+
isRunning(): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Wait for Chrome to output the DevTools WebSocket URL on stderr.
|
|
74
|
+
* Falls back to querying the /json/version endpoint.
|
|
75
|
+
*/
|
|
76
|
+
private waitForDebugUrl;
|
|
77
|
+
/**
|
|
78
|
+
* Fetch the DevTools WebSocket URL via the /json/version HTTP endpoint.
|
|
79
|
+
* Returns the browser-level WebSocket URL.
|
|
80
|
+
*/
|
|
81
|
+
private fetchDebugUrl;
|
|
82
|
+
/**
|
|
83
|
+
* Find the page-level WebSocket target via /json endpoint.
|
|
84
|
+
* Page targets support Page.enable, Runtime.evaluate, etc.
|
|
85
|
+
* The browser-level target only supports browser-wide commands.
|
|
86
|
+
*/
|
|
87
|
+
private findPageTarget;
|
|
88
|
+
/**
|
|
89
|
+
* Build the rrweb injection script source.
|
|
90
|
+
* Used by both addScriptToEvaluateOnNewDocument and Runtime.evaluate.
|
|
91
|
+
*/
|
|
92
|
+
private getRrwebInjectionScript;
|
|
93
|
+
/**
|
|
94
|
+
* Inject rrweb recording script into the current page.
|
|
95
|
+
* The script loads rrweb from CDN, then starts recording,
|
|
96
|
+
* emitting events through the __sessioncast_emit CDP binding.
|
|
97
|
+
*/
|
|
98
|
+
private injectRrweb;
|
|
99
|
+
}
|
|
100
|
+
export {};
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.BrowserHandler = void 0;
|
|
37
|
+
const child_process_1 = require("child_process");
|
|
38
|
+
const chrome_finder_1 = require("./chrome-finder");
|
|
39
|
+
const cdp_client_1 = require("./cdp-client");
|
|
40
|
+
const debug_1 = require("./debug");
|
|
41
|
+
// rrweb CDN — v1.x reliably exposes global `rrweb.record` via <script> tag.
|
|
42
|
+
// The v2 Replayer (used in the web client) is backward-compatible with v1 events.
|
|
43
|
+
const RRWEB_CDN = 'https://cdn.jsdelivr.net/npm/rrweb@1.1.3/dist/rrweb.min.js';
|
|
44
|
+
/**
|
|
45
|
+
* Manages a headless Chrome instance with rrweb-based DOM recording.
|
|
46
|
+
* Spawns Chrome with --headless=new, connects via CDP, injects rrweb,
|
|
47
|
+
* and streams DOM mutation events through a callback.
|
|
48
|
+
*/
|
|
49
|
+
class BrowserHandler {
|
|
50
|
+
constructor() {
|
|
51
|
+
this.chrome = null;
|
|
52
|
+
this.cdp = null;
|
|
53
|
+
this.domCallback = null;
|
|
54
|
+
this.eventBuffer = [];
|
|
55
|
+
this.running = false;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Launch headless Chrome and connect CDP.
|
|
59
|
+
* @param port Remote debugging port for Chrome
|
|
60
|
+
* @param opts Optional browser configuration
|
|
61
|
+
*/
|
|
62
|
+
async start(port, opts = {}) {
|
|
63
|
+
const chromePath = opts.chromePath || (0, chrome_finder_1.findChromePath)();
|
|
64
|
+
if (!chromePath) {
|
|
65
|
+
throw new Error('Chrome/Chromium not found. Install Chrome or pass chromePath in options.');
|
|
66
|
+
}
|
|
67
|
+
const width = opts.width || 1280;
|
|
68
|
+
const height = opts.height || 720;
|
|
69
|
+
const url = opts.url || 'about:blank';
|
|
70
|
+
const args = [
|
|
71
|
+
'--headless=new',
|
|
72
|
+
`--remote-debugging-port=${port}`,
|
|
73
|
+
`--window-size=${width},${height}`,
|
|
74
|
+
'--disable-gpu',
|
|
75
|
+
'--no-first-run',
|
|
76
|
+
'--no-default-browser-check',
|
|
77
|
+
'--disable-extensions',
|
|
78
|
+
'--disable-background-networking',
|
|
79
|
+
'--disable-sync',
|
|
80
|
+
'--disable-translate',
|
|
81
|
+
'--mute-audio',
|
|
82
|
+
url,
|
|
83
|
+
];
|
|
84
|
+
(0, debug_1.debugLog)('Browser', 'Launching', chromePath, args.join(' '));
|
|
85
|
+
this.chrome = (0, child_process_1.spawn)(chromePath, args, {
|
|
86
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
87
|
+
});
|
|
88
|
+
// Wait for Chrome to be ready, then find the page-level WebSocket target.
|
|
89
|
+
// We must connect to a page target (not the browser target) for Page.enable to work.
|
|
90
|
+
await this.waitForDebugUrl(port);
|
|
91
|
+
const pageUrl = await this.findPageTarget(port);
|
|
92
|
+
(0, debug_1.debugLog)('Browser', 'Page DevTools URL:', pageUrl);
|
|
93
|
+
// Connect CDP to the page target
|
|
94
|
+
this.cdp = new cdp_client_1.CdpClient();
|
|
95
|
+
await this.cdp.connect(pageUrl);
|
|
96
|
+
this.running = true;
|
|
97
|
+
// Enable required CDP domains
|
|
98
|
+
await this.cdp.send('Page.enable');
|
|
99
|
+
await this.cdp.send('Runtime.enable');
|
|
100
|
+
// Set up the binding that rrweb will call to emit events
|
|
101
|
+
await this.cdp.send('Runtime.addBinding', {
|
|
102
|
+
name: '__sessioncast_emit',
|
|
103
|
+
});
|
|
104
|
+
// Listen for the binding call — buffer events if callback isn't registered yet
|
|
105
|
+
this.cdp.on('Runtime.bindingCalled', (params) => {
|
|
106
|
+
if (params.name === '__sessioncast_emit') {
|
|
107
|
+
try {
|
|
108
|
+
const event = JSON.parse(params.payload);
|
|
109
|
+
if (this.domCallback) {
|
|
110
|
+
this.domCallback(event);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
this.eventBuffer.push(event);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
(0, debug_1.debugLog)('Browser', 'Failed to parse DOM event:', e.message);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
// Detect page navigation → take fresh FullSnapshot for viewers.
|
|
122
|
+
// addScriptToEvaluateOnNewDocument handles rrweb re-injection,
|
|
123
|
+
// but binding-based event delivery is unreliable after navigation.
|
|
124
|
+
// This handler uses the direct-capture approach instead.
|
|
125
|
+
let navTimer = null;
|
|
126
|
+
this.cdp.on('Page.frameNavigated', (params) => {
|
|
127
|
+
if (params.frame?.parentId)
|
|
128
|
+
return; // skip iframes
|
|
129
|
+
(0, debug_1.debugLog)('Browser', 'Navigation detected:', params.frame?.url);
|
|
130
|
+
if (navTimer)
|
|
131
|
+
clearTimeout(navTimer);
|
|
132
|
+
navTimer = setTimeout(async () => {
|
|
133
|
+
if (!this.cdp || !this.running)
|
|
134
|
+
return;
|
|
135
|
+
try {
|
|
136
|
+
// Re-add binding for the new execution context
|
|
137
|
+
try {
|
|
138
|
+
await this.cdp.send('Runtime.addBinding', { name: '__sessioncast_emit' });
|
|
139
|
+
}
|
|
140
|
+
catch { /* binding may already exist */ }
|
|
141
|
+
// Wait for document to be ready
|
|
142
|
+
await this.cdp.send('Runtime.evaluate', {
|
|
143
|
+
expression: `new Promise(function(resolve) {
|
|
144
|
+
if (document.readyState !== 'loading') resolve();
|
|
145
|
+
else document.addEventListener('DOMContentLoaded', resolve);
|
|
146
|
+
})`,
|
|
147
|
+
awaitPromise: true,
|
|
148
|
+
});
|
|
149
|
+
// addScriptToEvaluateOnNewDocument may have set the flag but failed
|
|
150
|
+
// to load rrweb CDN. Reset and manually inject.
|
|
151
|
+
await this.cdp.send('Runtime.evaluate', {
|
|
152
|
+
expression: 'window.__sessioncast_recording = false;',
|
|
153
|
+
});
|
|
154
|
+
await this.injectRrweb();
|
|
155
|
+
if (!this.running)
|
|
156
|
+
return;
|
|
157
|
+
await this.takeFullSnapshot();
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
(0, debug_1.debugLog)('Browser', 'Post-navigation handler failed:', e.message);
|
|
161
|
+
}
|
|
162
|
+
}, 500);
|
|
163
|
+
});
|
|
164
|
+
// Use addScriptToEvaluateOnNewDocument so rrweb is injected
|
|
165
|
+
// on every navigation (including the initial load) automatically.
|
|
166
|
+
// This is more reliable than listening for frameNavigated.
|
|
167
|
+
await this.cdp.send('Page.addScriptToEvaluateOnNewDocument', {
|
|
168
|
+
source: this.getRrwebInjectionScript(),
|
|
169
|
+
});
|
|
170
|
+
// Also inject into the current page immediately (for already-loaded pages)
|
|
171
|
+
await this.injectRrweb();
|
|
172
|
+
// Handle Chrome exit
|
|
173
|
+
this.chrome.on('exit', (code) => {
|
|
174
|
+
(0, debug_1.debugLog)('Browser', 'Chrome exited with code', code);
|
|
175
|
+
this.running = false;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Register a callback for rrweb DOM events.
|
|
180
|
+
* Flushes any events buffered before the callback was registered.
|
|
181
|
+
*/
|
|
182
|
+
onDomEvent(cb) {
|
|
183
|
+
this.domCallback = cb;
|
|
184
|
+
// Flush buffered events (e.g. FullSnapshot emitted during start())
|
|
185
|
+
if (this.eventBuffer.length > 0) {
|
|
186
|
+
const buffered = this.eventBuffer;
|
|
187
|
+
this.eventBuffer = [];
|
|
188
|
+
for (const event of buffered) {
|
|
189
|
+
cb(event);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Dispatch an input event to the page via CDP.
|
|
195
|
+
* Accepts WebInputEvent from the web client (inputType field).
|
|
196
|
+
*/
|
|
197
|
+
async dispatchInput(event) {
|
|
198
|
+
if (!this.cdp || !this.running)
|
|
199
|
+
return;
|
|
200
|
+
// Support both 'inputType' (from web client) and 'type' (legacy)
|
|
201
|
+
const inputType = event.inputType || event.type || '';
|
|
202
|
+
if (inputType === 'mousePressed' || inputType === 'mouseReleased' || inputType === 'mouseMoved') {
|
|
203
|
+
// CDP expects button: 'none' for mouseMoved, 'left'/'right'/'middle' for press/release
|
|
204
|
+
const button = inputType === 'mouseMoved' ? 'none' : (event.button || 'left');
|
|
205
|
+
await this.cdp.send('Input.dispatchMouseEvent', {
|
|
206
|
+
type: inputType,
|
|
207
|
+
x: event.x || 0,
|
|
208
|
+
y: event.y || 0,
|
|
209
|
+
button,
|
|
210
|
+
clickCount: event.clickCount || (inputType === 'mousePressed' ? 1 : 0),
|
|
211
|
+
modifiers: event.modifiers || 0,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
else if (inputType === 'mouseWheel') {
|
|
215
|
+
await this.cdp.send('Input.dispatchMouseEvent', {
|
|
216
|
+
type: 'mouseWheel',
|
|
217
|
+
x: event.x || 0,
|
|
218
|
+
y: event.y || 0,
|
|
219
|
+
deltaX: event.deltaX || 0,
|
|
220
|
+
deltaY: event.deltaY || 0,
|
|
221
|
+
modifiers: event.modifiers || 0,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
else if (inputType === 'keyDown' || inputType === 'keyUp') {
|
|
225
|
+
await this.cdp.send('Input.dispatchKeyEvent', {
|
|
226
|
+
type: inputType,
|
|
227
|
+
key: event.key || '',
|
|
228
|
+
code: event.code || '',
|
|
229
|
+
text: event.text || (inputType === 'keyDown' && event.key?.length === 1 ? event.key : ''),
|
|
230
|
+
modifiers: event.modifiers || 0,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Trigger rrweb to emit a fresh FullSnapshot by restarting the recording.
|
|
236
|
+
* Stops the current recording and starts a new one, which immediately
|
|
237
|
+
* produces a FullSnapshot of the current DOM state.
|
|
238
|
+
*/
|
|
239
|
+
async takeFullSnapshot() {
|
|
240
|
+
if (!this.cdp || !this.running)
|
|
241
|
+
return;
|
|
242
|
+
try {
|
|
243
|
+
// Stop existing recording, restart, and collect the FullSnapshot
|
|
244
|
+
// all within Chrome's JS context, then return the snapshot data.
|
|
245
|
+
const result = await this.cdp.send('Runtime.evaluate', {
|
|
246
|
+
expression: `
|
|
247
|
+
(function() {
|
|
248
|
+
// Stop existing recording
|
|
249
|
+
if (window.__sessioncast_stopFn) {
|
|
250
|
+
window.__sessioncast_stopFn();
|
|
251
|
+
window.__sessioncast_stopFn = null;
|
|
252
|
+
}
|
|
253
|
+
// Capture the FullSnapshot synchronously
|
|
254
|
+
var captured = null;
|
|
255
|
+
if (typeof rrweb !== 'undefined' && rrweb.record) {
|
|
256
|
+
window.__sessioncast_stopFn = rrweb.record({
|
|
257
|
+
emit: function(event) {
|
|
258
|
+
if (event.type === 2 && !captured) {
|
|
259
|
+
captured = JSON.stringify(event);
|
|
260
|
+
}
|
|
261
|
+
try { __sessioncast_emit(JSON.stringify(event)); } catch(e) {}
|
|
262
|
+
},
|
|
263
|
+
sampling: {
|
|
264
|
+
mousemove: 50,
|
|
265
|
+
mouseInteraction: true,
|
|
266
|
+
scroll: 150,
|
|
267
|
+
input: 'last',
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return captured || '';
|
|
272
|
+
})();
|
|
273
|
+
`,
|
|
274
|
+
returnByValue: true,
|
|
275
|
+
});
|
|
276
|
+
const snapshotJson = result?.result?.value;
|
|
277
|
+
if (snapshotJson && snapshotJson.length > 0) {
|
|
278
|
+
(0, debug_1.debugLog)('Browser', 'takeFullSnapshot: captured', snapshotJson.length, 'bytes, emitting via binding');
|
|
279
|
+
// The binding call inside evaluate might be queued —
|
|
280
|
+
// also emit directly from Node.js side as fallback
|
|
281
|
+
if (this.domCallback) {
|
|
282
|
+
try {
|
|
283
|
+
const event = JSON.parse(snapshotJson);
|
|
284
|
+
this.domCallback(event);
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
(0, debug_1.debugLog)('Browser', 'takeFullSnapshot: parse error', e.message);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
(0, debug_1.debugLog)('Browser', 'takeFullSnapshot: no snapshot captured (rrweb may not be loaded)');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
(0, debug_1.debugLog)('Browser', 'takeFullSnapshot failed:', e.message);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Navigate the browser to a new URL.
|
|
301
|
+
* Only localhost/127.0.0.1 URLs are allowed for security.
|
|
302
|
+
*/
|
|
303
|
+
async navigate(url) {
|
|
304
|
+
if (!this.cdp || !this.running) {
|
|
305
|
+
throw new Error('Browser is not running');
|
|
306
|
+
}
|
|
307
|
+
// Security: only allow localhost navigation
|
|
308
|
+
try {
|
|
309
|
+
const parsed = new URL(url);
|
|
310
|
+
if (parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
|
|
311
|
+
throw new Error(`Navigation blocked: only localhost URLs are allowed (got ${parsed.hostname})`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
if (e.message.includes('Navigation blocked'))
|
|
316
|
+
throw e;
|
|
317
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
318
|
+
}
|
|
319
|
+
(0, debug_1.debugLog)('Browser', 'Navigating to', url);
|
|
320
|
+
await this.cdp.send('Page.navigate', { url });
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Stop Chrome and close the CDP connection.
|
|
324
|
+
*/
|
|
325
|
+
stop() {
|
|
326
|
+
this.running = false;
|
|
327
|
+
if (this.cdp) {
|
|
328
|
+
this.cdp.close();
|
|
329
|
+
this.cdp = null;
|
|
330
|
+
}
|
|
331
|
+
if (this.chrome) {
|
|
332
|
+
this.chrome.kill('SIGTERM');
|
|
333
|
+
this.chrome = null;
|
|
334
|
+
}
|
|
335
|
+
this.domCallback = null;
|
|
336
|
+
(0, debug_1.debugLog)('Browser', 'Stopped');
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Check whether the browser is currently running.
|
|
340
|
+
*/
|
|
341
|
+
isRunning() {
|
|
342
|
+
return this.running;
|
|
343
|
+
}
|
|
344
|
+
// -- Private methods --
|
|
345
|
+
/**
|
|
346
|
+
* Wait for Chrome to output the DevTools WebSocket URL on stderr.
|
|
347
|
+
* Falls back to querying the /json/version endpoint.
|
|
348
|
+
*/
|
|
349
|
+
waitForDebugUrl(port) {
|
|
350
|
+
return new Promise((resolve, reject) => {
|
|
351
|
+
const timeout = setTimeout(() => {
|
|
352
|
+
// Fallback: query the HTTP endpoint
|
|
353
|
+
this.fetchDebugUrl(port).then(resolve).catch(reject);
|
|
354
|
+
}, 5000);
|
|
355
|
+
if (!this.chrome?.stderr) {
|
|
356
|
+
clearTimeout(timeout);
|
|
357
|
+
// No stderr available, try HTTP fallback directly
|
|
358
|
+
setTimeout(() => {
|
|
359
|
+
this.fetchDebugUrl(port).then(resolve).catch(reject);
|
|
360
|
+
}, 1000);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
let buffer = '';
|
|
364
|
+
const onData = (data) => {
|
|
365
|
+
buffer += data.toString();
|
|
366
|
+
// Chrome outputs: DevTools listening on ws://127.0.0.1:PORT/devtools/browser/UUID
|
|
367
|
+
const match = buffer.match(/DevTools listening on (ws:\/\/[^\s]+)/);
|
|
368
|
+
if (match) {
|
|
369
|
+
clearTimeout(timeout);
|
|
370
|
+
this.chrome?.stderr?.removeListener('data', onData);
|
|
371
|
+
resolve(match[1]);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
this.chrome.stderr.on('data', onData);
|
|
375
|
+
this.chrome.on('exit', (code) => {
|
|
376
|
+
clearTimeout(timeout);
|
|
377
|
+
reject(new Error(`Chrome exited unexpectedly with code ${code}`));
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Fetch the DevTools WebSocket URL via the /json/version HTTP endpoint.
|
|
383
|
+
* Returns the browser-level WebSocket URL.
|
|
384
|
+
*/
|
|
385
|
+
async fetchDebugUrl(port) {
|
|
386
|
+
const fetch = (await Promise.resolve().then(() => __importStar(require('node-fetch')))).default;
|
|
387
|
+
const maxRetries = 5;
|
|
388
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
389
|
+
try {
|
|
390
|
+
const res = await fetch(`http://127.0.0.1:${port}/json/version`);
|
|
391
|
+
const json = (await res.json());
|
|
392
|
+
if (json.webSocketDebuggerUrl) {
|
|
393
|
+
return json.webSocketDebuggerUrl;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// Chrome may not be ready yet
|
|
398
|
+
}
|
|
399
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
400
|
+
}
|
|
401
|
+
throw new Error(`Could not get DevTools URL from Chrome on port ${port}`);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Find the page-level WebSocket target via /json endpoint.
|
|
405
|
+
* Page targets support Page.enable, Runtime.evaluate, etc.
|
|
406
|
+
* The browser-level target only supports browser-wide commands.
|
|
407
|
+
*/
|
|
408
|
+
async findPageTarget(port) {
|
|
409
|
+
const fetch = (await Promise.resolve().then(() => __importStar(require('node-fetch')))).default;
|
|
410
|
+
const maxRetries = 10;
|
|
411
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
412
|
+
try {
|
|
413
|
+
const res = await fetch(`http://127.0.0.1:${port}/json`);
|
|
414
|
+
const targets = (await res.json());
|
|
415
|
+
const page = targets.find(t => t.type === 'page');
|
|
416
|
+
if (page?.webSocketDebuggerUrl) {
|
|
417
|
+
return page.webSocketDebuggerUrl;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
// Chrome may not be ready yet
|
|
422
|
+
}
|
|
423
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
424
|
+
}
|
|
425
|
+
throw new Error(`Could not find page target on Chrome port ${port}`);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Build the rrweb injection script source.
|
|
429
|
+
* Used by both addScriptToEvaluateOnNewDocument and Runtime.evaluate.
|
|
430
|
+
*/
|
|
431
|
+
getRrwebInjectionScript() {
|
|
432
|
+
return `
|
|
433
|
+
(async function() {
|
|
434
|
+
if (window.__sessioncast_recording) return;
|
|
435
|
+
window.__sessioncast_recording = true;
|
|
436
|
+
|
|
437
|
+
// Load rrweb from CDN
|
|
438
|
+
await new Promise((resolve, reject) => {
|
|
439
|
+
const s = document.createElement('script');
|
|
440
|
+
s.src = '${RRWEB_CDN}';
|
|
441
|
+
s.onload = resolve;
|
|
442
|
+
s.onerror = reject;
|
|
443
|
+
(document.head || document.documentElement).appendChild(s);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Start recording (save stop function for later restart)
|
|
447
|
+
if (typeof rrweb !== 'undefined' && rrweb.record) {
|
|
448
|
+
window.__sessioncast_stopFn = rrweb.record({
|
|
449
|
+
emit(event) {
|
|
450
|
+
try {
|
|
451
|
+
__sessioncast_emit(JSON.stringify(event));
|
|
452
|
+
} catch(e) { /* binding may be gone */ }
|
|
453
|
+
},
|
|
454
|
+
sampling: {
|
|
455
|
+
mousemove: 50,
|
|
456
|
+
mouseInteraction: true,
|
|
457
|
+
scroll: 150,
|
|
458
|
+
input: 'last',
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
})();
|
|
463
|
+
`;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Inject rrweb recording script into the current page.
|
|
467
|
+
* The script loads rrweb from CDN, then starts recording,
|
|
468
|
+
* emitting events through the __sessioncast_emit CDP binding.
|
|
469
|
+
*/
|
|
470
|
+
async injectRrweb() {
|
|
471
|
+
if (!this.cdp || !this.running)
|
|
472
|
+
return;
|
|
473
|
+
try {
|
|
474
|
+
await this.cdp.send('Runtime.evaluate', {
|
|
475
|
+
expression: this.getRrwebInjectionScript(),
|
|
476
|
+
awaitPromise: true,
|
|
477
|
+
});
|
|
478
|
+
(0, debug_1.debugLog)('Browser', 'rrweb injected successfully');
|
|
479
|
+
}
|
|
480
|
+
catch (e) {
|
|
481
|
+
(0, debug_1.debugLog)('Browser', 'rrweb injection failed:', e.message);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
exports.BrowserHandler = BrowserHandler;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
/**
|
|
3
|
+
* Lightweight Chrome DevTools Protocol client.
|
|
4
|
+
* Uses the existing `ws` package — no additional dependencies.
|
|
5
|
+
*/
|
|
6
|
+
export declare class CdpClient extends EventEmitter {
|
|
7
|
+
private ws;
|
|
8
|
+
private nextId;
|
|
9
|
+
private callbacks;
|
|
10
|
+
private connected;
|
|
11
|
+
/**
|
|
12
|
+
* Connect to a Chrome DevTools WebSocket URL.
|
|
13
|
+
* URL is obtained from Chrome's --remote-debugging-port output.
|
|
14
|
+
*/
|
|
15
|
+
connect(browserDebugUrl: string): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Send a CDP command and wait for the response.
|
|
18
|
+
*/
|
|
19
|
+
send(method: string, params?: Record<string, any>): Promise<any>;
|
|
20
|
+
/**
|
|
21
|
+
* Check if connected.
|
|
22
|
+
*/
|
|
23
|
+
isConnected(): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Close the CDP connection.
|
|
26
|
+
*/
|
|
27
|
+
close(): void;
|
|
28
|
+
}
|