@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.
@@ -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
+ }