@activeagents/browser-sidecar 0.0.0-stage → 1.9.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.
- package/LICENSE +21 -0
- package/README.md +227 -2
- package/bin/browser-sidecar.mjs +85 -0
- package/lib/config.mjs +191 -0
- package/lib/control-lock.mjs +210 -0
- package/lib/egress-proxy.mjs +127 -0
- package/lib/guard.mjs +86 -0
- package/lib/http-server.mjs +124 -0
- package/lib/input.mjs +137 -0
- package/lib/live-server.mjs +281 -0
- package/lib/live-ticket.mjs +126 -0
- package/lib/mcp-gateway.mjs +339 -0
- package/lib/navigation-guard.mjs +87 -0
- package/lib/network-policy.mjs +264 -0
- package/lib/recorder.mjs +348 -0
- package/lib/screencast.mjs +181 -0
- package/lib/sidecar.mjs +252 -0
- package/lib/storage-state.mjs +76 -0
- package/lib/version.mjs +5 -0
- package/package.json +36 -4
package/lib/recorder.mjs
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { gzipSync } from 'node:zlib';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
// What one call from a page may carry, and the console levels kept.
|
|
10
|
+
const MAX_PAYLOAD_CHARS = 8 * 1024 * 1024;
|
|
11
|
+
const CONSOLE_LEVELS = new Set(['error', 'warning']);
|
|
12
|
+
const CONSOLE_TEXT_LIMIT = 2000;
|
|
13
|
+
// Room left in a batch for the envelope around its events.
|
|
14
|
+
const ENVELOPE_BYTES = 1024;
|
|
15
|
+
// How long a page holds rrweb events before handing them over.
|
|
16
|
+
export const PAGE_FLUSH_MS = 250;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The browser build of @rrweb/record, as the source of a script.
|
|
20
|
+
*
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function rrwebSource() {
|
|
24
|
+
return readFileSync(join(dirname(require.resolve('@rrweb/record')), 'record.umd.min.cjs'), 'utf8');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Text inside an editable element. maskAllInputs covers only input,
|
|
28
|
+
// textarea and select values, and text typed into a contenteditable host
|
|
29
|
+
// (a rich-text editor, say) is recorded as DOM text instead.
|
|
30
|
+
export const EDITABLE_TEXT = '[contenteditable]:not([contenteditable="false"])';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns the init script that records each page with rrweb and hands its
|
|
34
|
+
* events, as JSON text, to the binding named `binding`. Input values, and
|
|
35
|
+
* the text of contenteditable elements and their descendants, are masked
|
|
36
|
+
* before an event leaves the page. The bundle runs with a module object of
|
|
37
|
+
* its own, so it defines no global the page could reach.
|
|
38
|
+
*
|
|
39
|
+
* @param {{ binding: string, rrweb: string, flushMs?: number, flushEvents?: number }} options
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function initScript({ binding, rrweb, flushMs = PAGE_FLUSH_MS, flushEvents = 50 }) {
|
|
43
|
+
return `(() => {
|
|
44
|
+
const deliver = window[${JSON.stringify(binding)}];
|
|
45
|
+
if (typeof deliver !== 'function') return;
|
|
46
|
+
const rrwebRecord = (function () {
|
|
47
|
+
var module = { exports: {} };
|
|
48
|
+
var exports = module.exports;
|
|
49
|
+
${rrweb}
|
|
50
|
+
return module.exports;
|
|
51
|
+
})();
|
|
52
|
+
let buffer = [];
|
|
53
|
+
let timer = null;
|
|
54
|
+
const flush = () => {
|
|
55
|
+
timer = null;
|
|
56
|
+
if (buffer.length === 0) return;
|
|
57
|
+
const batch = buffer;
|
|
58
|
+
buffer = [];
|
|
59
|
+
try { deliver(JSON.stringify(batch)); } catch (error) {}
|
|
60
|
+
};
|
|
61
|
+
rrwebRecord.record({
|
|
62
|
+
emit(event) {
|
|
63
|
+
buffer.push(event);
|
|
64
|
+
if (buffer.length >= ${flushEvents}) flush();
|
|
65
|
+
else if (timer === null) timer = setTimeout(flush, ${flushMs});
|
|
66
|
+
},
|
|
67
|
+
maskAllInputs: true,
|
|
68
|
+
maskTextSelector: ${JSON.stringify(EDITABLE_TEXT)},
|
|
69
|
+
recordCrossOriginIframes: true,
|
|
70
|
+
sampling: { mousemove: 50, scroll: 150, input: 'last' },
|
|
71
|
+
});
|
|
72
|
+
addEventListener('pagehide', flush);
|
|
73
|
+
})();`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Used to group recorded events into batches the recording ingest accepts:
|
|
78
|
+
* at most `maxEvents` events and `maxBytes` bytes of JSON each. A batch is
|
|
79
|
+
* handed to `onBatch` as soon as it is full, or `flushMs` after its first
|
|
80
|
+
* event. An event too large for any batch is dropped.
|
|
81
|
+
*/
|
|
82
|
+
export class EventBatcher {
|
|
83
|
+
constructor({ maxEvents, maxBytes, flushMs = 1000, onBatch, onDrop = () => {}, now = Date.now }) {
|
|
84
|
+
this.maxEvents = maxEvents;
|
|
85
|
+
this.maxBytes = Math.max(maxBytes - ENVELOPE_BYTES, 1);
|
|
86
|
+
this.flushMs = flushMs;
|
|
87
|
+
this.onBatch = onBatch;
|
|
88
|
+
this.onDrop = onDrop;
|
|
89
|
+
this.now = now;
|
|
90
|
+
this.parts = [];
|
|
91
|
+
this.bytes = 0;
|
|
92
|
+
this.timer = null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {{ kind: string, timestamp: number, data: object }} event
|
|
97
|
+
*/
|
|
98
|
+
add(event) {
|
|
99
|
+
const part = JSON.stringify(event);
|
|
100
|
+
const size = Buffer.byteLength(part) + 1;
|
|
101
|
+
if (size > this.maxBytes) {
|
|
102
|
+
this.onDrop(event, 'larger than a batch may be');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (this.parts.length >= this.maxEvents || this.bytes + size > this.maxBytes) this.flush();
|
|
106
|
+
|
|
107
|
+
this.parts.push(part);
|
|
108
|
+
this.bytes += size;
|
|
109
|
+
if (this.parts.length >= this.maxEvents) this.flush();
|
|
110
|
+
else if (this.timer === null) this.timer = setTimeout(() => this.flush(), this.flushMs);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
flush() {
|
|
114
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
115
|
+
this.timer = null;
|
|
116
|
+
if (this.parts.length === 0) return;
|
|
117
|
+
|
|
118
|
+
const body = `{"sent_at":${this.now()},"recording_events":[${this.parts.join(',')}]}`;
|
|
119
|
+
const count = this.parts.length;
|
|
120
|
+
this.parts = [];
|
|
121
|
+
this.bytes = 0;
|
|
122
|
+
this.onBatch(body, count);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Used to post batches to the recording ingest, gzipped, one at a time and
|
|
128
|
+
* in order. A failed post is retried with backoff; a batch the ingest
|
|
129
|
+
* refuses is dropped; a refused token stops the uploads, since every later
|
|
130
|
+
* batch would be refused too.
|
|
131
|
+
*/
|
|
132
|
+
export class Uploader {
|
|
133
|
+
constructor({ url, token, fetch = globalThis.fetch, log = () => {}, retries = 3, backoffMs = 500, maxQueue = 50 }) {
|
|
134
|
+
this.url = url;
|
|
135
|
+
this.token = token;
|
|
136
|
+
this.fetch = fetch;
|
|
137
|
+
this.log = log;
|
|
138
|
+
this.retries = retries;
|
|
139
|
+
this.backoffMs = backoffMs;
|
|
140
|
+
this.maxQueue = maxQueue;
|
|
141
|
+
this.queue = [];
|
|
142
|
+
this.running = null;
|
|
143
|
+
this.stopped = false;
|
|
144
|
+
this.dropped = 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
enqueue(body, count) {
|
|
148
|
+
if (this.stopped) {
|
|
149
|
+
this.dropped += count;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (this.queue.length >= this.maxQueue) {
|
|
153
|
+
const oldest = this.queue.shift();
|
|
154
|
+
this.dropped += oldest.count;
|
|
155
|
+
this.log(`recording: dropped a batch of ${oldest.count} events, the upload queue is full`);
|
|
156
|
+
}
|
|
157
|
+
this.queue.push({ body: gzipSync(body), count });
|
|
158
|
+
this.running ??= this.run();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async run() {
|
|
162
|
+
try {
|
|
163
|
+
while (this.queue.length > 0 && !this.stopped) {
|
|
164
|
+
await this.post(this.queue.shift());
|
|
165
|
+
}
|
|
166
|
+
} finally {
|
|
167
|
+
this.running = null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async post(batch) {
|
|
172
|
+
for (let attempt = 0; attempt <= this.retries; attempt += 1) {
|
|
173
|
+
let status;
|
|
174
|
+
try {
|
|
175
|
+
const response = await this.fetch(this.url, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: {
|
|
178
|
+
authorization: `Bearer ${this.token}`,
|
|
179
|
+
'content-type': 'application/json',
|
|
180
|
+
'content-encoding': 'gzip',
|
|
181
|
+
},
|
|
182
|
+
body: batch.body,
|
|
183
|
+
});
|
|
184
|
+
status = response.status;
|
|
185
|
+
if (status < 300) return;
|
|
186
|
+
if (status === 401 || status === 403) {
|
|
187
|
+
this.stopped = true;
|
|
188
|
+
this.dropped += batch.count + this.queue.reduce((total, queued) => total + queued.count, 0);
|
|
189
|
+
this.queue = [];
|
|
190
|
+
this.log(`recording: the ingest refused the recording token (HTTP ${status}); recording stopped`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (status < 500 && status !== 429) {
|
|
194
|
+
this.dropped += batch.count;
|
|
195
|
+
this.log(`recording: the ingest refused a batch of ${batch.count} events (HTTP ${status})`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
} catch (error) {
|
|
199
|
+
status = error.code ?? error.name;
|
|
200
|
+
}
|
|
201
|
+
if (attempt < this.retries) await new Promise((resolve) => setTimeout(resolve, this.backoffMs * 2 ** attempt));
|
|
202
|
+
else this.log(`recording: gave up on a batch of ${batch.count} events (${status})`);
|
|
203
|
+
}
|
|
204
|
+
this.dropped += batch.count;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Waits for queued batches to post, up to `timeoutMs`.
|
|
209
|
+
*
|
|
210
|
+
* @param {number} timeoutMs
|
|
211
|
+
*/
|
|
212
|
+
async drain(timeoutMs) {
|
|
213
|
+
let timer;
|
|
214
|
+
const deadline = new Promise((resolve) => {
|
|
215
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
216
|
+
});
|
|
217
|
+
try {
|
|
218
|
+
while (this.running) {
|
|
219
|
+
const done = await Promise.race([this.running.then(() => true), deadline.then(() => false)]);
|
|
220
|
+
if (!done) return;
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A marker's URL without its query or fragment, which can carry tokens.
|
|
229
|
+
function markerUrl(url) {
|
|
230
|
+
try {
|
|
231
|
+
const parsed = new URL(url);
|
|
232
|
+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return `${parsed.origin}${parsed.pathname}`;
|
|
233
|
+
return url === 'about:blank' ? url : null;
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function validRrwebEvent(event) {
|
|
240
|
+
return event !== null && typeof event === 'object' && Number.isInteger(event.type) && Number.isFinite(event.timestamp);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Used to record a browser context into a session recording: rrweb from every
|
|
245
|
+
* page's main frame, console errors and warnings, and markers for pages
|
|
246
|
+
* opening, navigating and closing.
|
|
247
|
+
*/
|
|
248
|
+
export class Recorder {
|
|
249
|
+
/**
|
|
250
|
+
* @param {object} options
|
|
251
|
+
* @param {{ url: string, token: string, batchEvents: number, batchBytes: number }} options.recording
|
|
252
|
+
* @param {(message: string) => void} [options.log]
|
|
253
|
+
* @param {typeof fetch} [options.fetch]
|
|
254
|
+
*/
|
|
255
|
+
constructor({ recording, log = () => {}, fetch = globalThis.fetch }) {
|
|
256
|
+
this.log = log;
|
|
257
|
+
this.binding = `__aa_${randomBytes(8).toString('hex')}`;
|
|
258
|
+
this.uploader = new Uploader({ url: recording.url, token: recording.token, fetch, log });
|
|
259
|
+
this.batcher = new EventBatcher({
|
|
260
|
+
maxEvents: recording.batchEvents,
|
|
261
|
+
maxBytes: recording.batchBytes,
|
|
262
|
+
onBatch: (body, count) => this.uploader.enqueue(body, count),
|
|
263
|
+
onDrop: (_event, reason) => log(`recording: dropped an event ${reason}`),
|
|
264
|
+
});
|
|
265
|
+
this.tabs = new WeakMap();
|
|
266
|
+
this.nextTab = 1;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Starts recording `context`: every page opened from now on, in every frame.
|
|
271
|
+
*
|
|
272
|
+
* @param {import('playwright').BrowserContext} context
|
|
273
|
+
*/
|
|
274
|
+
async attach(context) {
|
|
275
|
+
await context.exposeBinding(this.binding, (source, payload) => this.receive(source, payload));
|
|
276
|
+
await context.addInitScript({ content: initScript({ binding: this.binding, rrweb: rrwebSource() }) });
|
|
277
|
+
|
|
278
|
+
context.on('console', (message) => this.console(message));
|
|
279
|
+
context.on('page', (page) => this.watch(page));
|
|
280
|
+
for (const page of context.pages()) this.watch(page);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
tab(page) {
|
|
284
|
+
if (!this.tabs.has(page)) this.tabs.set(page, this.nextTab++);
|
|
285
|
+
return this.tabs.get(page);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
watch(page) {
|
|
289
|
+
const tab = this.tab(page);
|
|
290
|
+
this.marker({ label: 'page_opened', tab, url: markerUrl(page.url()) });
|
|
291
|
+
page.on('framenavigated', (frame) => {
|
|
292
|
+
if (frame === page.mainFrame()) this.marker({ label: 'navigated', tab, url: markerUrl(frame.url()) });
|
|
293
|
+
});
|
|
294
|
+
page.on('close', () => this.marker({ label: 'page_closed', tab }));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
marker(data) {
|
|
298
|
+
this.batcher.add({ kind: 'marker', timestamp: Date.now(), data });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
console(message) {
|
|
302
|
+
if (!CONSOLE_LEVELS.has(message.type())) return;
|
|
303
|
+
|
|
304
|
+
const page = message.page();
|
|
305
|
+
this.batcher.add({
|
|
306
|
+
kind: 'console',
|
|
307
|
+
timestamp: Date.now(),
|
|
308
|
+
data: { level: message.type(), text: message.text().slice(0, CONSOLE_TEXT_LIMIT), tab: page ? this.tab(page) : null },
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Takes a batch of rrweb events a page handed to the binding. Only a main
|
|
314
|
+
* frame's are kept: rrweb in a child frame passes its events up to the main
|
|
315
|
+
* frame's recorder instead.
|
|
316
|
+
*
|
|
317
|
+
* @param {{ page?: object, frame?: object }} source
|
|
318
|
+
* @param {unknown} payload JSON text of an array of rrweb events
|
|
319
|
+
*/
|
|
320
|
+
receive(source, payload) {
|
|
321
|
+
if (!source?.page || source.frame !== source.page.mainFrame()) return;
|
|
322
|
+
if (typeof payload !== 'string' || payload.length > MAX_PAYLOAD_CHARS) return;
|
|
323
|
+
|
|
324
|
+
let events;
|
|
325
|
+
try {
|
|
326
|
+
events = JSON.parse(payload);
|
|
327
|
+
} catch {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (!Array.isArray(events)) return;
|
|
331
|
+
|
|
332
|
+
const tab = this.tab(source.page);
|
|
333
|
+
for (const event of events) {
|
|
334
|
+
if (validRrwebEvent(event)) this.batcher.add({ kind: 'rrweb', timestamp: event.timestamp, data: { ...event, tab } });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Sends what is buffered and waits for it to post, up to `timeoutMs`.
|
|
340
|
+
*
|
|
341
|
+
* @param {number} timeoutMs
|
|
342
|
+
*/
|
|
343
|
+
async close(timeoutMs) {
|
|
344
|
+
this.batcher.flush();
|
|
345
|
+
await this.uploader.drain(timeoutMs);
|
|
346
|
+
if (this.uploader.dropped > 0) this.log(`recording: ${this.uploader.dropped} events were not stored`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// What each frame is, as Page.startScreencast takes it. Frames are scaled
|
|
2
|
+
// down to fit; the metadata keeps the page's own size for input.
|
|
3
|
+
export const SCREENCAST_OPTIONS = { format: 'jpeg', quality: 60, maxWidth: 1280, maxHeight: 800, everyNthFrame: 1 };
|
|
4
|
+
|
|
5
|
+
// A page's URL without its query or fragment, which can carry tokens.
|
|
6
|
+
export function pageUrl(url) {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = new URL(url);
|
|
9
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? `${parsed.origin}${parsed.pathname}` : url === 'about:blank' ? url : null;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Used to stream the page on screen as JPEG frames, over the Chrome DevTools
|
|
17
|
+
* Protocol (Page.startScreencast), and to send it input.
|
|
18
|
+
*
|
|
19
|
+
* The page on screen is the newest open page: a tab or popup that opens
|
|
20
|
+
* takes the screen, and when the page on screen closes, the newest one left
|
|
21
|
+
* does. #follow puts another page on screen.
|
|
22
|
+
*
|
|
23
|
+
* Chromium sends a frame only when the page changes, and stops sending until
|
|
24
|
+
* each one is acknowledged, so every frame is acknowledged as it arrives and
|
|
25
|
+
* the latest is kept (#lastFrame) for a viewer who joins while the page is
|
|
26
|
+
* still.
|
|
27
|
+
*/
|
|
28
|
+
export class Screencast {
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} options
|
|
31
|
+
* @param {import('playwright').BrowserContext} options.context
|
|
32
|
+
* @param {(frame: { data: string, metadata: object }) => void} options.onFrame
|
|
33
|
+
* @param {() => void} [options.onPageChange] called when the page on screen, or its address, changes
|
|
34
|
+
* @param {(message: string) => void} [options.log]
|
|
35
|
+
* @param {object} [options.screencastOptions]
|
|
36
|
+
*/
|
|
37
|
+
constructor({ context, onFrame, onPageChange = () => {}, log = () => {}, screencastOptions = SCREENCAST_OPTIONS }) {
|
|
38
|
+
this.context = context;
|
|
39
|
+
this.onFrame = onFrame;
|
|
40
|
+
this.onPageChange = onPageChange;
|
|
41
|
+
this.log = log;
|
|
42
|
+
this.screencastOptions = screencastOptions;
|
|
43
|
+
this.page = null;
|
|
44
|
+
this.session = null;
|
|
45
|
+
this.streaming = false;
|
|
46
|
+
this.lastFrame = null;
|
|
47
|
+
// Each change of page or of streaming runs after the one before.
|
|
48
|
+
this.queue = Promise.resolve();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Starts following the context's pages.
|
|
52
|
+
attach() {
|
|
53
|
+
const watch = (page) => {
|
|
54
|
+
page.on('close', () => {
|
|
55
|
+
if (page === this.page) void this.follow(this.newestPage());
|
|
56
|
+
});
|
|
57
|
+
page.on('framenavigated', (frame) => {
|
|
58
|
+
if (page === this.page && frame === page.mainFrame()) this.onPageChange();
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
for (const page of this.context.pages()) watch(page);
|
|
62
|
+
this.context.on('page', (page) => {
|
|
63
|
+
watch(page);
|
|
64
|
+
void this.follow(page);
|
|
65
|
+
});
|
|
66
|
+
return this.follow(this.newestPage());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
newestPage() {
|
|
70
|
+
const open = this.context.pages().filter((page) => !page.isClosed());
|
|
71
|
+
return open.at(-1) ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The page on screen as a viewer is told of it.
|
|
75
|
+
get pageInfo() {
|
|
76
|
+
if (!this.page) return null;
|
|
77
|
+
const pages = this.context.pages().filter((page) => !page.isClosed());
|
|
78
|
+
return { url: pageUrl(this.page.url()), tab: pages.indexOf(this.page) + 1, tabs: pages.length };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get metadata() {
|
|
82
|
+
return this.lastFrame?.metadata ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Puts `page` on screen.
|
|
87
|
+
*
|
|
88
|
+
* @param {import('playwright').Page | null} page
|
|
89
|
+
*/
|
|
90
|
+
follow(page) {
|
|
91
|
+
return this.enqueue(async () => {
|
|
92
|
+
if (page === this.page) return;
|
|
93
|
+
await this.detach();
|
|
94
|
+
this.page = page;
|
|
95
|
+
this.onPageChange();
|
|
96
|
+
if (page && this.streaming) await this.startOn(page);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Puts the page at `index` among the open pages on screen.
|
|
102
|
+
*
|
|
103
|
+
* @param {number} index
|
|
104
|
+
*/
|
|
105
|
+
followIndex(index) {
|
|
106
|
+
const page = this.context.pages().filter((candidate) => !candidate.isClosed())[index];
|
|
107
|
+
return page ? this.follow(page) : Promise.resolve();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Starts streaming the page on screen.
|
|
111
|
+
start() {
|
|
112
|
+
return this.enqueue(async () => {
|
|
113
|
+
if (this.streaming) return;
|
|
114
|
+
this.streaming = true;
|
|
115
|
+
if (this.page) await this.startOn(this.page);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Stops streaming; the last frame is kept.
|
|
120
|
+
stop() {
|
|
121
|
+
return this.enqueue(async () => {
|
|
122
|
+
this.streaming = false;
|
|
123
|
+
await this.detach();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Sends one CDP command to the page on screen.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} method
|
|
131
|
+
* @param {object} params
|
|
132
|
+
*/
|
|
133
|
+
async dispatch(method, params) {
|
|
134
|
+
const session = this.session ?? (await this.enqueue(() => this.sessionFor(this.page)));
|
|
135
|
+
if (!session) return;
|
|
136
|
+
await session.send(method, params);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async sessionFor(page) {
|
|
140
|
+
if (!page || page.isClosed()) return null;
|
|
141
|
+
if (this.session) return this.session;
|
|
142
|
+
|
|
143
|
+
const session = await this.context.newCDPSession(page);
|
|
144
|
+
session.on('Page.screencastFrame', ({ data, metadata, sessionId }) => {
|
|
145
|
+
session.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
|
|
146
|
+
if (session !== this.session || !this.streaming) return;
|
|
147
|
+
|
|
148
|
+
this.lastFrame = { data, metadata };
|
|
149
|
+
this.onFrame(this.lastFrame);
|
|
150
|
+
});
|
|
151
|
+
this.session = session;
|
|
152
|
+
return session;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async startOn(page) {
|
|
156
|
+
try {
|
|
157
|
+
const session = await this.sessionFor(page);
|
|
158
|
+
await session?.send('Page.startScreencast', this.screencastOptions);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.log(`live view: could not stream the page: ${error.message}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async detach() {
|
|
165
|
+
const session = this.session;
|
|
166
|
+
this.session = null;
|
|
167
|
+
if (!session) return;
|
|
168
|
+
await session.send('Page.stopScreencast').catch(() => {});
|
|
169
|
+
await session.detach().catch(() => {});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
enqueue(work) {
|
|
173
|
+
const run = this.queue.then(work);
|
|
174
|
+
this.queue = run.catch(() => {});
|
|
175
|
+
return run;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async close() {
|
|
179
|
+
await this.stop();
|
|
180
|
+
}
|
|
181
|
+
}
|