@push.rocks/smartpuppeteer 2.0.7 → 2.2.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,2975 @@
1
+ import { getEnvAwareBrowserInstance } from './smartpuppeteer.classes.smartpuppeteer.js';
2
+ import type {
3
+ ILiveBrowserClickOptions,
4
+ ILiveBrowserCreateTabOptions,
5
+ ILiveBrowserError,
6
+ ILiveBrowserEvaluateOptions,
7
+ ILiveBrowserFillOptions,
8
+ ILiveBrowserFrame,
9
+ ILiveBrowserFrameAcknowledgement,
10
+ ILiveBrowserFrameAcknowledgementRequest,
11
+ ILiveBrowserInsertTextInput,
12
+ ILiveBrowserKeyInput,
13
+ ILiveBrowserModifierState,
14
+ ILiveBrowserMouseInput,
15
+ ILiveBrowserNavigateOptions,
16
+ ILiveBrowserNavigationOptions,
17
+ ILiveBrowserObservation,
18
+ ILiveBrowserObserveOptions,
19
+ ILiveBrowserOperationOptions,
20
+ ILiveBrowserPressOptions,
21
+ ILiveBrowserSessionOptions,
22
+ ILiveBrowserSnapshot,
23
+ ILiveBrowserSnapshotOptions,
24
+ ILiveBrowserState,
25
+ ILiveBrowserTabState,
26
+ ILiveBrowserViewport,
27
+ ILiveBrowserWheelInput,
28
+ TLiveBrowserEvent,
29
+ TLiveBrowserEventListener,
30
+ TLiveBrowserImageFormat,
31
+ TLiveBrowserJsonValue,
32
+ TLiveBrowserWaitUntil,
33
+ } from './smartpuppeteer.interfaces.livebrowser.js';
34
+ import * as plugins from './smartpuppeteer.plugins.js';
35
+
36
+ const defaultViewport: ILiveBrowserViewport = {
37
+ width: 800,
38
+ height: 600,
39
+ deviceScaleFactor: 1,
40
+ };
41
+
42
+ const maxViewportWidth = 4096;
43
+ const maxViewportHeight = 4096;
44
+ const maxDeviceScaleFactor = 3;
45
+ const maxViewportPixelArea = 8294400;
46
+ const maxSelectorLength = 4096;
47
+ const maxTextLength = 32768;
48
+ const maxUrlLength = 16384;
49
+ const maxTimeoutMs = 60000;
50
+ const maxOutstandingFrames = 3;
51
+ const maxQueuedPublicOperations = 64;
52
+ const maxQueuedInternalOperations = 128;
53
+ const maxEvaluationScriptBytes = 262144;
54
+ const maxEvaluationOutputBytes = 1048576;
55
+ const maxEvaluationDepth = 32;
56
+ const maxEvaluationNodes = 50000;
57
+ const maxEvaluationStringBytes = 262144;
58
+ const maxEvaluationArrayLength = 10000;
59
+ const maxEvaluationObjectKeys = 10000;
60
+ const evaluationBootstrapKey = '__smartpuppeteerEvaluate';
61
+ const evaluationCancelKey = '__smartpuppeteerCancel';
62
+ const evaluationCleanupKey = '__smartpuppeteerCleanup';
63
+
64
+ type TScreencastFrameEvent = plugins.puppeteer.Protocol.Page.ScreencastFrameEvent;
65
+ type TScreencastFrameListener = (event: TScreencastFrameEvent) => void;
66
+ type TCdpSessionDetachedListener = (session: plugins.puppeteer.CDPSession) => void;
67
+
68
+ interface IPrivateLiveBrowserTab {
69
+ id: string;
70
+ page: plugins.puppeteer.Page;
71
+ url: string;
72
+ title: string;
73
+ status: 'open' | 'crashed';
74
+ generation: number;
75
+ appliedViewportRevision: number;
76
+ streaming: boolean;
77
+ streamInvalidated: boolean;
78
+ navigationInProgress: boolean;
79
+ closing: boolean;
80
+ stateUpdateQueued: boolean;
81
+ stateUpdatePending: boolean;
82
+ navigationResetPending: boolean;
83
+ evaluationExecutionContextId?: number;
84
+ securityCdpSession?: plugins.puppeteer.CDPSession;
85
+ cdpSession?: plugins.puppeteer.CDPSession;
86
+ cdpConnection?: plugins.puppeteer.Connection;
87
+ screencastFrameListener?: TScreencastFrameListener;
88
+ cdpSessionDetachedListener?: TCdpSessionDetachedListener;
89
+ removeListeners: Array<() => void>;
90
+ }
91
+
92
+ interface IOutstandingFrame {
93
+ tabId: string;
94
+ generation: number;
95
+ viewportRevision: number;
96
+ cdpSessionId: number;
97
+ cdpSession: plugins.puppeteer.CDPSession;
98
+ }
99
+
100
+ interface IImageDimensions {
101
+ width: number;
102
+ height: number;
103
+ }
104
+
105
+ interface INormalizedEvaluationOptions {
106
+ timeoutMs: number;
107
+ maxOutputBytes: number;
108
+ maxDepth: number;
109
+ maxNodes: number;
110
+ maxStringBytes: number;
111
+ maxArrayLength: number;
112
+ maxObjectKeys: number;
113
+ }
114
+
115
+ interface IEvaluationEnvelope {
116
+ ok: boolean;
117
+ json?: string;
118
+ error?: string;
119
+ }
120
+
121
+ type TQueuedOperationKind = 'public' | 'internal' | 'shutdown';
122
+ type TQueuedOperationState = 'queued' | 'active' | 'settled';
123
+
124
+ interface IQueuedOperation {
125
+ kind: TQueuedOperationKind;
126
+ state: TQueuedOperationState;
127
+ controller: AbortController;
128
+ run: (signal: AbortSignal) => Promise<unknown>;
129
+ resolve: (value: unknown) => void;
130
+ reject: (error: unknown) => void;
131
+ capacityReleased: boolean;
132
+ removeCallerAbortListener?: () => void;
133
+ }
134
+
135
+ const validateBoundedString = (
136
+ value: unknown,
137
+ name: string,
138
+ minLength: number,
139
+ maxLength: number,
140
+ ): string => {
141
+ if (
142
+ typeof value !== 'string'
143
+ || value.length < minLength
144
+ || value.length > maxLength
145
+ ) {
146
+ throw new Error(`${name} must contain between ${minLength} and ${maxLength} characters`);
147
+ }
148
+ return value;
149
+ };
150
+
151
+ const validateFiniteNumber = (
152
+ value: unknown,
153
+ name: string,
154
+ minimum: number,
155
+ maximum: number,
156
+ ): number => {
157
+ if (
158
+ typeof value !== 'number'
159
+ || !Number.isFinite(value)
160
+ || value < minimum
161
+ || value > maximum
162
+ ) {
163
+ throw new Error(`${name} must be a finite number between ${minimum} and ${maximum}`);
164
+ }
165
+ return value;
166
+ };
167
+
168
+ const validateInteger = (
169
+ value: unknown,
170
+ name: string,
171
+ minimum: number,
172
+ maximum: number,
173
+ ): number => {
174
+ const validatedValue = validateFiniteNumber(value, name, minimum, maximum);
175
+ if (!Number.isInteger(validatedValue)) {
176
+ throw new Error(`${name} must be an integer`);
177
+ }
178
+ return validatedValue;
179
+ };
180
+
181
+ const validateOptionalBoolean = (value: unknown, name: string): void => {
182
+ if (value !== undefined && typeof value !== 'boolean') {
183
+ throw new Error(`${name} must be a boolean`);
184
+ }
185
+ };
186
+
187
+ const truncate = (value: string, maxLength: number): string => {
188
+ if (value.length <= maxLength) {
189
+ return value;
190
+ }
191
+ return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
192
+ };
193
+
194
+ const normalizeErrorMessage = (error: unknown): string => {
195
+ if (error instanceof Error) {
196
+ return truncate(error.message, 2048);
197
+ }
198
+ return truncate(String(error), 2048);
199
+ };
200
+
201
+ const normalizeAbortReason = (signal: AbortSignal): unknown => {
202
+ return signal.reason ?? new Error('The browser operation was aborted');
203
+ };
204
+
205
+ const readUint32 = (data: Uint8Array, offset: number): number => {
206
+ return (
207
+ data[offset]! * 0x1000000
208
+ + data[offset + 1]! * 0x10000
209
+ + data[offset + 2]! * 0x100
210
+ + data[offset + 3]!
211
+ );
212
+ };
213
+
214
+ const readImageDimensions = (
215
+ data: Uint8Array,
216
+ format: TLiveBrowserImageFormat,
217
+ fallback: IImageDimensions,
218
+ ): IImageDimensions => {
219
+ if (
220
+ format === 'png'
221
+ && data.length >= 24
222
+ && data[0] === 0x89
223
+ && data[1] === 0x50
224
+ && data[2] === 0x4e
225
+ && data[3] === 0x47
226
+ ) {
227
+ return {
228
+ width: readUint32(data, 16),
229
+ height: readUint32(data, 20),
230
+ };
231
+ }
232
+
233
+ if (format === 'jpeg' && data.length >= 4 && data[0] === 0xff && data[1] === 0xd8) {
234
+ let offset = 2;
235
+ while (offset + 8 < data.length) {
236
+ if (data[offset] !== 0xff) {
237
+ offset += 1;
238
+ continue;
239
+ }
240
+ const marker = data[offset + 1]!;
241
+ if (marker === 0xd8 || marker === 0xd9) {
242
+ offset += 2;
243
+ continue;
244
+ }
245
+ const segmentLength = (data[offset + 2]! << 8) + data[offset + 3]!;
246
+ if (segmentLength < 2 || offset + segmentLength + 2 > data.length) {
247
+ break;
248
+ }
249
+ if (
250
+ marker === 0xc0
251
+ || marker === 0xc1
252
+ || marker === 0xc2
253
+ || marker === 0xc3
254
+ || marker === 0xc5
255
+ || marker === 0xc6
256
+ || marker === 0xc7
257
+ || marker === 0xc9
258
+ || marker === 0xca
259
+ || marker === 0xcb
260
+ || marker === 0xcd
261
+ || marker === 0xce
262
+ || marker === 0xcf
263
+ ) {
264
+ return {
265
+ height: (data[offset + 5]! << 8) + data[offset + 6]!,
266
+ width: (data[offset + 7]! << 8) + data[offset + 8]!,
267
+ };
268
+ }
269
+ offset += segmentLength + 2;
270
+ }
271
+ }
272
+
273
+ return fallback;
274
+ };
275
+
276
+ const normalizeViewport = (viewport: ILiveBrowserViewport): ILiveBrowserViewport => {
277
+ const normalizedViewport = {
278
+ width: validateInteger(viewport.width, 'viewport.width', 1, maxViewportWidth),
279
+ height: validateInteger(viewport.height, 'viewport.height', 1, maxViewportHeight),
280
+ deviceScaleFactor: validateFiniteNumber(
281
+ viewport.deviceScaleFactor,
282
+ 'viewport.deviceScaleFactor',
283
+ 0.25,
284
+ maxDeviceScaleFactor,
285
+ ),
286
+ };
287
+ const physicalWidth = Math.ceil(
288
+ normalizedViewport.width * normalizedViewport.deviceScaleFactor,
289
+ );
290
+ const physicalHeight = Math.ceil(
291
+ normalizedViewport.height * normalizedViewport.deviceScaleFactor,
292
+ );
293
+ if (physicalWidth * physicalHeight > maxViewportPixelArea) {
294
+ throw new Error(
295
+ `viewport pixel area must not exceed ${maxViewportPixelArea} physical pixels`,
296
+ );
297
+ }
298
+ return normalizedViewport;
299
+ };
300
+
301
+ const createPuppeteerViewport = (
302
+ viewport: ILiveBrowserViewport,
303
+ ): plugins.puppeteer.Viewport => ({
304
+ ...viewport,
305
+ isMobile: false,
306
+ isLandscape: false,
307
+ hasTouch: false,
308
+ });
309
+
310
+ export class LiveBrowserSession {
311
+ private readonly options: ILiveBrowserSessionOptions;
312
+ private readonly eventListeners = new Set<TLiveBrowserEventListener>();
313
+ private readonly tabs = new Map<string, IPrivateLiveBrowserTab>();
314
+ private readonly tabIdsByPage = new WeakMap<plugins.puppeteer.Page, string>();
315
+ private readonly outstandingFrames = new Map<number, IOutstandingFrame>();
316
+
317
+ private browser?: plugins.puppeteer.Browser;
318
+ private browserContext?: plugins.puppeteer.BrowserContext;
319
+ private browserLifetimeController?: AbortController;
320
+ private browserDisconnectedListener?: () => void;
321
+ private readonly operationQueue: IQueuedOperation[] = [];
322
+ private activeOperation?: IQueuedOperation;
323
+ private operationRunning = false;
324
+ private admittedPublicOperations = 0;
325
+ private admittedInternalOperations = 0;
326
+ private shutdownPromise?: Promise<void>;
327
+ private status: 'stopped' | 'starting' | 'running' | 'stopping' = 'stopped';
328
+ private activeTabId: string | null = null;
329
+ private viewport: ILiveBrowserViewport = { ...defaultViewport };
330
+ private viewportRevision = 1;
331
+ private tabSequence = 0;
332
+ private frameSequence = 0;
333
+ private evaluationSequence = 0;
334
+ private normalStopRequested = false;
335
+ private lastError?: ILiveBrowserError;
336
+
337
+ constructor(optionsArg: ILiveBrowserSessionOptions = {}) {
338
+ if (
339
+ optionsArg.launchOptions?.protocol
340
+ && optionsArg.launchOptions.protocol !== 'cdp'
341
+ ) {
342
+ throw new Error('LiveBrowserSession only supports Puppeteer CDP transport');
343
+ }
344
+ if (
345
+ optionsArg.launchOptions?.browser
346
+ && optionsArg.launchOptions.browser !== 'chrome'
347
+ ) {
348
+ throw new Error('LiveBrowserSession requires Chromium');
349
+ }
350
+ if (optionsArg.launchOptions && 'signal' in optionsArg.launchOptions) {
351
+ throw new Error('LiveBrowserSession owns launch cancellation; launchOptions.signal is unsupported');
352
+ }
353
+ validateOptionalBoolean(optionsArg.allowEvaluation, 'allowEvaluation');
354
+ for (const [name, value] of Object.entries(optionsArg.security ?? {})) {
355
+ validateOptionalBoolean(value, `security.${name}`);
356
+ }
357
+ const launchViewport = optionsArg.launchOptions?.defaultViewport;
358
+ const viewport = normalizeViewport(
359
+ optionsArg.viewport
360
+ ?? (launchViewport
361
+ ? {
362
+ width: launchViewport.width,
363
+ height: launchViewport.height,
364
+ deviceScaleFactor: launchViewport.deviceScaleFactor ?? 1,
365
+ }
366
+ : defaultViewport),
367
+ );
368
+ this.options = {
369
+ ...optionsArg,
370
+ launchOptions: {
371
+ ...optionsArg.launchOptions,
372
+ args: [...(optionsArg.launchOptions?.args ?? [])],
373
+ defaultViewport: createPuppeteerViewport(viewport),
374
+ ...(optionsArg.security?.denyDownloads
375
+ ? { downloadBehavior: { policy: 'deny' as const } }
376
+ : {}),
377
+ },
378
+ viewport,
379
+ screencast: optionsArg.screencast ? { ...optionsArg.screencast } : undefined,
380
+ security: optionsArg.security ? { ...optionsArg.security } : undefined,
381
+ };
382
+ this.viewport = { ...viewport };
383
+ this.validateScreencastOptions();
384
+ }
385
+
386
+ public onEvent(listener: TLiveBrowserEventListener): () => void {
387
+ if (typeof listener !== 'function') {
388
+ throw new Error('listener must be a function');
389
+ }
390
+ this.eventListeners.add(listener);
391
+ return () => {
392
+ this.eventListeners.delete(listener);
393
+ };
394
+ }
395
+
396
+ public getState(): ILiveBrowserState {
397
+ return {
398
+ status: this.status,
399
+ activeTabId: this.activeTabId,
400
+ viewportRevision: this.viewportRevision,
401
+ viewport: { ...this.viewport },
402
+ tabs: [...this.tabs.values()].map((tab) => this.createTabState(tab)),
403
+ ...(this.lastError ? { lastError: { ...this.lastError } } : {}),
404
+ };
405
+ }
406
+
407
+ public async start(operationOptions: ILiveBrowserOperationOptions = {}): Promise<void> {
408
+ return this.enqueuePublicOperation(async (signal) => {
409
+ if (this.status === 'running' || this.status === 'starting') {
410
+ return;
411
+ }
412
+
413
+ this.normalStopRequested = false;
414
+ this.lastError = undefined;
415
+ this.viewportRevision = 1;
416
+ const browserLifetimeController = new AbortController();
417
+ this.browserLifetimeController = browserLifetimeController;
418
+ this.status = 'starting';
419
+ this.emitState();
420
+
421
+ try {
422
+ if (signal.aborted) {
423
+ throw signal.reason;
424
+ }
425
+ if (browserLifetimeController.signal.aborted) {
426
+ throw browserLifetimeController.signal.reason;
427
+ }
428
+ const abortBrowserLaunch = (): void => {
429
+ if (!browserLifetimeController.signal.aborted) {
430
+ browserLifetimeController.abort(normalizeAbortReason(signal));
431
+ }
432
+ };
433
+ signal.addEventListener('abort', abortBrowserLaunch, { once: true });
434
+ try {
435
+ this.browser = await getEnvAwareBrowserInstance({
436
+ forceNoSandbox: this.options.forceNoSandbox,
437
+ requireSandbox: this.options.requireSandbox,
438
+ usePipe: this.options.usePipe,
439
+ launchOptions: {
440
+ ...this.options.launchOptions,
441
+ protocol: 'cdp',
442
+ signal: browserLifetimeController.signal,
443
+ },
444
+ });
445
+ } finally {
446
+ signal.removeEventListener('abort', abortBrowserLaunch);
447
+ }
448
+ if (signal.aborted) {
449
+ throw signal.reason;
450
+ }
451
+ this.browserContext = this.browser.defaultBrowserContext();
452
+ await this.configureBrowserSecurity(this.browser);
453
+ this.browserDisconnectedListener = () => {
454
+ if (this.normalStopRequested || this.status === 'stopped' || this.status === 'stopping') {
455
+ return;
456
+ }
457
+ const error: ILiveBrowserError = {
458
+ code: 'browser_disconnected',
459
+ message: 'The Chromium process disconnected',
460
+ fatal: true,
461
+ };
462
+ this.emitError(error);
463
+ void this.requestShutdown(error).catch((cleanupError) => {
464
+ this.emitError({
465
+ code: 'browser_disconnect_cleanup_failed',
466
+ message: normalizeErrorMessage(cleanupError),
467
+ fatal: true,
468
+ });
469
+ });
470
+ };
471
+ this.browser.on('disconnected', this.browserDisconnectedListener);
472
+
473
+ const initialPages = await this.browserContext.pages();
474
+ if (initialPages.length === 0) {
475
+ initialPages.push(await this.browserContext.newPage());
476
+ }
477
+ const initialPage = initialPages[0]!;
478
+ if (initialPage.url() !== 'about:blank') {
479
+ await initialPage.goto('about:blank');
480
+ }
481
+
482
+ for (const page of initialPages) {
483
+ await this.registerPage(page);
484
+ }
485
+
486
+ const firstTab = this.tabs.values().next().value as IPrivateLiveBrowserTab | undefined;
487
+ if (!firstTab) {
488
+ throw new Error('Chromium did not provide an initial page');
489
+ }
490
+ this.activeTabId = firstTab.id;
491
+ await firstTab.page.bringToFront();
492
+ this.status = 'running';
493
+ this.emitState();
494
+ await this.startScreencast(firstTab);
495
+ if (signal.aborted) {
496
+ throw normalizeAbortReason(signal);
497
+ }
498
+ } catch (error) {
499
+ if (signal.aborted || this.normalStopRequested) {
500
+ await this.stopInternal();
501
+ throw error;
502
+ }
503
+ const startError: ILiveBrowserError = {
504
+ code: 'start_failed',
505
+ message: normalizeErrorMessage(error),
506
+ fatal: true,
507
+ };
508
+ this.emitError(startError);
509
+ await this.stopInternal(startError);
510
+ throw error;
511
+ }
512
+ }, operationOptions);
513
+ }
514
+
515
+ public async stop(): Promise<void> {
516
+ this.normalStopRequested = true;
517
+ return this.requestShutdown();
518
+ }
519
+
520
+ public async acknowledgeFrame(
521
+ acknowledgement: ILiveBrowserFrameAcknowledgementRequest,
522
+ ): Promise<ILiveBrowserFrameAcknowledgement> {
523
+ if (!acknowledgement || typeof acknowledgement !== 'object') {
524
+ return { accepted: false };
525
+ }
526
+ const { tabId, sequence, generation, viewportRevision } = acknowledgement;
527
+ if (
528
+ typeof tabId !== 'string'
529
+ || tabId.length < 1
530
+ || tabId.length > 128
531
+ || !Number.isInteger(sequence)
532
+ || sequence < 1
533
+ || !Number.isInteger(generation)
534
+ || generation < 0
535
+ || !Number.isInteger(viewportRevision)
536
+ || viewportRevision < 1
537
+ ) {
538
+ return { accepted: false };
539
+ }
540
+ const outstandingFrame = this.outstandingFrames.get(sequence);
541
+ if (!outstandingFrame) {
542
+ return { accepted: false };
543
+ }
544
+ if (
545
+ outstandingFrame.tabId !== tabId
546
+ || outstandingFrame.generation !== generation
547
+ || outstandingFrame.viewportRevision !== viewportRevision
548
+ ) {
549
+ return { accepted: false };
550
+ }
551
+
552
+ const tab = this.tabs.get(outstandingFrame.tabId);
553
+ if (
554
+ !tab
555
+ || this.activeTabId !== tab.id
556
+ || tab.generation !== outstandingFrame.generation
557
+ || this.viewportRevision !== outstandingFrame.viewportRevision
558
+ || tab.cdpSession !== outstandingFrame.cdpSession
559
+ || outstandingFrame.cdpSession.detached
560
+ ) {
561
+ this.outstandingFrames.delete(sequence);
562
+ await this.acknowledgeCdpFrame(outstandingFrame);
563
+ return { accepted: false };
564
+ }
565
+
566
+ this.outstandingFrames.delete(sequence);
567
+ return {
568
+ accepted: await this.acknowledgeCdpFrame(outstandingFrame),
569
+ };
570
+ }
571
+
572
+ public async createTab(
573
+ optionsArg: ILiveBrowserCreateTabOptions = {},
574
+ operationOptions: ILiveBrowserOperationOptions = {},
575
+ ): Promise<ILiveBrowserTabState> {
576
+ const url = optionsArg.url === undefined ? undefined : this.validateUrl(optionsArg.url);
577
+ const activate = optionsArg.activate ?? true;
578
+ validateOptionalBoolean(optionsArg.activate, 'activate');
579
+ const timeout = this.validateTimeout(optionsArg.timeoutMs, 30000);
580
+ const waitUntil = this.validateWaitUntil(optionsArg.waitUntil);
581
+ return this.enqueuePublicOperation(async (signal) => {
582
+ const context = this.requireBrowserContext();
583
+ const previousActiveTabId = this.activeTabId;
584
+ let page: plugins.puppeteer.Page | undefined;
585
+ let tab: IPrivateLiveBrowserTab | undefined;
586
+ try {
587
+ page = await context.newPage();
588
+ if (signal.aborted) {
589
+ throw signal.reason;
590
+ }
591
+ tab = await this.registerPage(page);
592
+ if (url !== undefined) {
593
+ await this.navigateTab(
594
+ tab,
595
+ async () => {
596
+ await tab!.page.goto(url, { timeout, waitUntil, signal });
597
+ },
598
+ signal,
599
+ );
600
+ }
601
+
602
+ if (activate) {
603
+ await this.activateTabInternal(tab.id);
604
+ } else {
605
+ const previousActiveTab = previousActiveTabId
606
+ ? this.tabs.get(previousActiveTabId)
607
+ : undefined;
608
+ if (previousActiveTab && !previousActiveTab.page.isClosed()) {
609
+ await previousActiveTab.page.bringToFront();
610
+ }
611
+ this.emitState();
612
+ }
613
+ return this.createTabState(tab);
614
+ } catch (error) {
615
+ if (page) {
616
+ const rollbackError = await this.rollbackCreatedPage(
617
+ page,
618
+ tab,
619
+ previousActiveTabId,
620
+ );
621
+ if (rollbackError) {
622
+ throw new AggregateError(
623
+ [error, rollbackError],
624
+ 'Tab creation failed and its page could not be closed cleanly',
625
+ );
626
+ }
627
+ }
628
+ throw error;
629
+ }
630
+ }, operationOptions);
631
+ }
632
+
633
+ public async activateTab(
634
+ tabId: string,
635
+ operationOptions: ILiveBrowserOperationOptions = {},
636
+ ): Promise<void> {
637
+ return this.enqueuePublicOperation(async () => {
638
+ await this.activateTabInternal(tabId);
639
+ }, operationOptions);
640
+ }
641
+
642
+ public async closeTab(
643
+ tabId: string,
644
+ operationOptions: ILiveBrowserOperationOptions = {},
645
+ ): Promise<void> {
646
+ return this.enqueuePublicOperation(async () => {
647
+ const tab = this.requireTab(tabId);
648
+ const wasActive = this.activeTabId === tab.id;
649
+ if (wasActive) {
650
+ await this.stopScreencast(tab);
651
+ } else {
652
+ await this.retireOutstandingFrames((frame) => frame.tabId === tab.id);
653
+ }
654
+ try {
655
+ if (!tab.page.isClosed()) {
656
+ await tab.page.close();
657
+ }
658
+ } catch (error) {
659
+ if (
660
+ wasActive
661
+ && this.status === 'running'
662
+ && this.activeTabId === tab.id
663
+ && tab.status === 'open'
664
+ ) {
665
+ await this.startScreencast(tab);
666
+ }
667
+ this.emitState();
668
+ throw error;
669
+ }
670
+
671
+ tab.closing = true;
672
+ this.removePageListeners(tab);
673
+ this.tabs.delete(tab.id);
674
+ this.tabIdsByPage.delete(tab.page);
675
+
676
+ if (wasActive) {
677
+ this.activeTabId = null;
678
+ await this.activateReplacementTab();
679
+ } else {
680
+ this.emitState();
681
+ }
682
+ }, operationOptions);
683
+ }
684
+
685
+ public async navigate(
686
+ optionsArg: ILiveBrowserNavigateOptions,
687
+ operationOptions: ILiveBrowserOperationOptions = {},
688
+ ): Promise<void> {
689
+ const url = this.validateUrl(optionsArg.url);
690
+ return this.enqueuePublicOperation(async (signal) => {
691
+ const tab = this.resolveNavigationTab(optionsArg.tabId);
692
+ await this.navigateTab(
693
+ tab,
694
+ async () => {
695
+ await tab.page.goto(url, this.createPuppeteerNavigationOptions(optionsArg, signal));
696
+ },
697
+ signal,
698
+ );
699
+ }, operationOptions);
700
+ }
701
+
702
+ public async back(
703
+ optionsArg: ILiveBrowserNavigationOptions = {},
704
+ operationOptions: ILiveBrowserOperationOptions = {},
705
+ ): Promise<void> {
706
+ return this.enqueuePublicOperation(async (signal) => {
707
+ const tab = this.resolveNavigationTab(optionsArg.tabId);
708
+ await this.navigateTab(
709
+ tab,
710
+ async () => {
711
+ await tab.page.goBack(this.createPuppeteerNavigationOptions(optionsArg, signal));
712
+ },
713
+ signal,
714
+ );
715
+ }, operationOptions);
716
+ }
717
+
718
+ public async forward(
719
+ optionsArg: ILiveBrowserNavigationOptions = {},
720
+ operationOptions: ILiveBrowserOperationOptions = {},
721
+ ): Promise<void> {
722
+ return this.enqueuePublicOperation(async (signal) => {
723
+ const tab = this.resolveNavigationTab(optionsArg.tabId);
724
+ await this.navigateTab(
725
+ tab,
726
+ async () => {
727
+ await tab.page.goForward(this.createPuppeteerNavigationOptions(optionsArg, signal));
728
+ },
729
+ signal,
730
+ );
731
+ }, operationOptions);
732
+ }
733
+
734
+ public async reload(
735
+ optionsArg: ILiveBrowserNavigationOptions = {},
736
+ operationOptions: ILiveBrowserOperationOptions = {},
737
+ ): Promise<void> {
738
+ return this.enqueuePublicOperation(async (signal) => {
739
+ const tab = this.resolveNavigationTab(optionsArg.tabId);
740
+ await this.navigateTab(
741
+ tab,
742
+ async () => {
743
+ await tab.page.reload(this.createPuppeteerNavigationOptions(optionsArg, signal));
744
+ },
745
+ signal,
746
+ );
747
+ }, operationOptions);
748
+ }
749
+
750
+ public async setViewport(
751
+ viewportArg: ILiveBrowserViewport,
752
+ operationOptions: ILiveBrowserOperationOptions = {},
753
+ ): Promise<void> {
754
+ const viewport = normalizeViewport(viewportArg);
755
+ return this.enqueuePublicOperation(async (signal) => {
756
+ const tab = this.requireActiveTab();
757
+ await this.stopScreencast(tab);
758
+ try {
759
+ if (signal.aborted) {
760
+ throw signal.reason;
761
+ }
762
+ await tab.page.setViewport(createPuppeteerViewport(viewport));
763
+ this.viewport = { ...viewport };
764
+ this.viewportRevision += 1;
765
+ tab.appliedViewportRevision = this.viewportRevision;
766
+ this.emitState();
767
+ } finally {
768
+ if (this.status === 'running' && this.activeTabId === tab.id && tab.status === 'open') {
769
+ await this.startScreencast(tab);
770
+ }
771
+ }
772
+ }, operationOptions);
773
+ }
774
+
775
+ public async dispatchMouse(input: ILiveBrowserMouseInput): Promise<void> {
776
+ const { tab, cdpSession } = this.requireRawInputTarget(input);
777
+ this.validateCoordinates(input.x, input.y);
778
+ const type = input.type === 'move'
779
+ ? 'mouseMoved'
780
+ : input.type === 'down'
781
+ ? 'mousePressed'
782
+ : input.type === 'up'
783
+ ? 'mouseReleased'
784
+ : undefined;
785
+ if (!type) {
786
+ throw new Error('mouse input type must be move, down, or up');
787
+ }
788
+ const allowedButtons = ['none', 'left', 'middle', 'right', 'back', 'forward'];
789
+ const button = input.button ?? (type === 'mouseMoved' ? 'none' : 'left');
790
+ if (!allowedButtons.includes(button)) {
791
+ throw new Error('mouse button is invalid');
792
+ }
793
+ const buttons = input.buttons === undefined
794
+ ? undefined
795
+ : validateInteger(input.buttons, 'buttons', 0, 31);
796
+ const clickCount = input.clickCount === undefined
797
+ ? undefined
798
+ : validateInteger(input.clickCount, 'clickCount', 0, 3);
799
+
800
+ try {
801
+ await cdpSession.send('Input.dispatchMouseEvent', {
802
+ type,
803
+ x: input.x,
804
+ y: input.y,
805
+ button,
806
+ buttons,
807
+ clickCount,
808
+ modifiers: this.createModifierMask(input.modifiers),
809
+ pointerType: 'mouse',
810
+ });
811
+ } catch (error) {
812
+ this.handlePossibleCdpDisconnection(tab, cdpSession, error);
813
+ throw error;
814
+ }
815
+ }
816
+
817
+ public async dispatchWheel(input: ILiveBrowserWheelInput): Promise<void> {
818
+ const { tab, cdpSession } = this.requireRawInputTarget(input);
819
+ this.validateCoordinates(input.x, input.y);
820
+ validateFiniteNumber(input.deltaX, 'deltaX', -1000000, 1000000);
821
+ validateFiniteNumber(input.deltaY, 'deltaY', -1000000, 1000000);
822
+ try {
823
+ await cdpSession.send('Input.dispatchMouseEvent', {
824
+ type: 'mouseWheel',
825
+ x: input.x,
826
+ y: input.y,
827
+ deltaX: input.deltaX,
828
+ deltaY: input.deltaY,
829
+ modifiers: this.createModifierMask(input.modifiers),
830
+ pointerType: 'mouse',
831
+ });
832
+ } catch (error) {
833
+ this.handlePossibleCdpDisconnection(tab, cdpSession, error);
834
+ throw error;
835
+ }
836
+ }
837
+
838
+ public async dispatchKey(input: ILiveBrowserKeyInput): Promise<void> {
839
+ const { tab, cdpSession } = this.requireRawInputTarget(input);
840
+ const type = input.type === 'down'
841
+ ? 'keyDown'
842
+ : input.type === 'up'
843
+ ? 'keyUp'
844
+ : undefined;
845
+ if (!type) {
846
+ throw new Error('key input type must be down or up');
847
+ }
848
+ const key = validateBoundedString(input.key, 'key', 1, 64);
849
+ const code = input.code === undefined
850
+ ? undefined
851
+ : validateBoundedString(input.code, 'code', 1, 64);
852
+ const text = input.text === undefined
853
+ ? undefined
854
+ : validateBoundedString(input.text, 'text', 0, 1024);
855
+ const unmodifiedText = input.unmodifiedText === undefined
856
+ ? undefined
857
+ : validateBoundedString(input.unmodifiedText, 'unmodifiedText', 0, 1024);
858
+ const windowsVirtualKeyCode = input.windowsVirtualKeyCode === undefined
859
+ ? undefined
860
+ : validateInteger(input.windowsVirtualKeyCode, 'windowsVirtualKeyCode', 0, 65535);
861
+ const nativeVirtualKeyCode = input.nativeVirtualKeyCode === undefined
862
+ ? undefined
863
+ : validateInteger(input.nativeVirtualKeyCode, 'nativeVirtualKeyCode', 0, 65535);
864
+ const location = input.location === undefined
865
+ ? undefined
866
+ : validateInteger(input.location, 'location', 0, 3);
867
+ validateOptionalBoolean(input.autoRepeat, 'autoRepeat');
868
+ validateOptionalBoolean(input.isKeypad, 'isKeypad');
869
+
870
+ try {
871
+ await cdpSession.send('Input.dispatchKeyEvent', {
872
+ type,
873
+ key,
874
+ code,
875
+ text,
876
+ unmodifiedText,
877
+ windowsVirtualKeyCode,
878
+ nativeVirtualKeyCode,
879
+ autoRepeat: input.autoRepeat,
880
+ isKeypad: input.isKeypad,
881
+ location,
882
+ modifiers: this.createModifierMask(input.modifiers),
883
+ });
884
+ } catch (error) {
885
+ this.handlePossibleCdpDisconnection(tab, cdpSession, error);
886
+ throw error;
887
+ }
888
+ }
889
+
890
+ public async insertText(input: ILiveBrowserInsertTextInput): Promise<void> {
891
+ const { tab, cdpSession } = this.requireRawInputTarget(input);
892
+ const text = validateBoundedString(input.text, 'text', 0, maxTextLength);
893
+ try {
894
+ await cdpSession.send('Input.insertText', { text });
895
+ } catch (error) {
896
+ this.handlePossibleCdpDisconnection(tab, cdpSession, error);
897
+ throw error;
898
+ }
899
+ }
900
+
901
+ public async captureSnapshot(
902
+ optionsArg: ILiveBrowserSnapshotOptions = {},
903
+ operationOptions: ILiveBrowserOperationOptions = {},
904
+ ): Promise<ILiveBrowserSnapshot> {
905
+ const format = optionsArg.format ?? 'jpeg';
906
+ if (format !== 'jpeg' && format !== 'png') {
907
+ throw new Error('snapshot format must be jpeg or png');
908
+ }
909
+ const quality = optionsArg.quality === undefined
910
+ ? undefined
911
+ : validateInteger(optionsArg.quality, 'quality', 0, 100);
912
+ if (format === 'png' && quality !== undefined) {
913
+ throw new Error('quality is only supported for jpeg snapshots');
914
+ }
915
+ if ('fullPage' in optionsArg) {
916
+ throw new Error('fullPage snapshots are not supported by LiveBrowserSession');
917
+ }
918
+ return this.enqueuePublicOperation(async (signal) => {
919
+ const tab = this.resolveActionTab(optionsArg.tabId);
920
+ await this.ensureTabViewport(tab);
921
+ const capturedTab = this.resolveActionTab(tab.id);
922
+ if (signal.aborted) {
923
+ throw signal.reason;
924
+ }
925
+ const viewport = { ...this.viewport };
926
+ const viewportRevision = this.viewportRevision;
927
+ const data = await capturedTab.page.screenshot({
928
+ type: format,
929
+ ...(format === 'jpeg' && quality !== undefined ? { quality } : {}),
930
+ });
931
+ if (signal.aborted) {
932
+ throw signal.reason;
933
+ }
934
+ const dimensions = readImageDimensions(data, format, {
935
+ width: Math.round(viewport.width * viewport.deviceScaleFactor),
936
+ height: Math.round(viewport.height * viewport.deviceScaleFactor),
937
+ });
938
+ return {
939
+ tabId: capturedTab.id,
940
+ viewportRevision,
941
+ viewport,
942
+ format,
943
+ mimeType: format === 'jpeg' ? 'image/jpeg' : 'image/png',
944
+ ...dimensions,
945
+ data,
946
+ };
947
+ }, operationOptions);
948
+ }
949
+
950
+ public async observe(
951
+ optionsArg: ILiveBrowserObserveOptions = {},
952
+ operationOptions: ILiveBrowserOperationOptions = {},
953
+ ): Promise<ILiveBrowserObservation> {
954
+ const maxCharacters = optionsArg.maxCharacters === undefined
955
+ ? 12000
956
+ : validateInteger(optionsArg.maxCharacters, 'maxCharacters', 256, 50000);
957
+ return this.enqueuePublicOperation(async (signal) => {
958
+ const tab = this.resolveActionTab(optionsArg.tabId);
959
+ await this.ensureTabViewport(tab);
960
+ const observedTab = this.resolveActionTab(tab.id);
961
+ if (signal.aborted) {
962
+ throw signal.reason;
963
+ }
964
+ await this.refreshTab(observedTab);
965
+ const accessibilitySnapshot = await observedTab.page.accessibility.snapshot({
966
+ interestingOnly: true,
967
+ });
968
+ if (signal.aborted) {
969
+ throw signal.reason;
970
+ }
971
+ const header = [
972
+ `Tab: ${observedTab.id}`,
973
+ `Status: ${observedTab.status}`,
974
+ `Title: ${observedTab.title}`,
975
+ `URL: ${observedTab.url}`,
976
+ 'Accessibility:',
977
+ ];
978
+ const lines = [...header];
979
+ let reachedTraversalLimit = false;
980
+
981
+ const appendNode = (
982
+ node: plugins.puppeteer.SerializedAXNode,
983
+ depth: number,
984
+ ): void => {
985
+ if (lines.join('\n').length >= maxCharacters) {
986
+ reachedTraversalLimit = true;
987
+ return;
988
+ }
989
+ const details: string[] = [node.role];
990
+ if (node.name) {
991
+ details.push(`"${truncate(node.name, 512)}"`);
992
+ }
993
+ if (node.value !== undefined) {
994
+ details.push(`value="${truncate(String(node.value), 512)}"`);
995
+ }
996
+ for (const property of [
997
+ 'disabled',
998
+ 'expanded',
999
+ 'focused',
1000
+ 'readonly',
1001
+ 'required',
1002
+ 'selected',
1003
+ 'checked',
1004
+ 'pressed',
1005
+ ] as const) {
1006
+ const value = node[property];
1007
+ if (value !== undefined && value !== false) {
1008
+ details.push(`${property}=${String(value)}`);
1009
+ }
1010
+ }
1011
+ lines.push(`${' '.repeat(Math.min(depth, 20))}- ${details.join(' ')}`);
1012
+ for (const child of node.children ?? []) {
1013
+ appendNode(child, depth + 1);
1014
+ if (reachedTraversalLimit) {
1015
+ break;
1016
+ }
1017
+ }
1018
+ };
1019
+
1020
+ if (accessibilitySnapshot) {
1021
+ appendNode(accessibilitySnapshot, 0);
1022
+ } else {
1023
+ lines.push('- No accessibility nodes');
1024
+ }
1025
+
1026
+ const unboundedText = lines.join('\n');
1027
+ const truncatedText = unboundedText.length > maxCharacters
1028
+ ? unboundedText.slice(0, maxCharacters)
1029
+ : unboundedText;
1030
+ const state = this.getState();
1031
+ return {
1032
+ tabId: observedTab.id,
1033
+ url: observedTab.url,
1034
+ title: observedTab.title,
1035
+ tab: this.createTabState(observedTab),
1036
+ state,
1037
+ text: truncatedText,
1038
+ truncated: reachedTraversalLimit || unboundedText.length > maxCharacters,
1039
+ };
1040
+ }, operationOptions);
1041
+ }
1042
+
1043
+ public async evaluate(
1044
+ expressionArg: string,
1045
+ optionsArg: ILiveBrowserEvaluateOptions = {},
1046
+ operationOptions: ILiveBrowserOperationOptions = {},
1047
+ ): Promise<TLiveBrowserJsonValue> {
1048
+ if (!this.options.allowEvaluation) {
1049
+ throw new Error('LiveBrowserSession evaluation is disabled');
1050
+ }
1051
+ const expression = validateBoundedString(
1052
+ expressionArg,
1053
+ 'expression',
1054
+ 1,
1055
+ maxEvaluationScriptBytes,
1056
+ );
1057
+ const expressionBytes = new TextEncoder().encode(expression).byteLength;
1058
+ if (expressionBytes > maxEvaluationScriptBytes) {
1059
+ throw new Error(`expression must not exceed ${maxEvaluationScriptBytes} UTF-8 bytes`);
1060
+ }
1061
+ const evaluationOptions = this.normalizeEvaluationOptions(optionsArg);
1062
+ const evaluationId = ++this.evaluationSequence;
1063
+ const cancellationKey = `__smartpuppeteerCancel${evaluationId}`;
1064
+ const evaluationExpression = this.createEvaluationExpression(
1065
+ expression,
1066
+ evaluationOptions,
1067
+ cancellationKey,
1068
+ );
1069
+
1070
+ return this.enqueuePublicOperation(async (signal) => {
1071
+ const tab = this.resolveActionTab(optionsArg.tabId);
1072
+ const cdpSession = await tab.page.createCDPSession();
1073
+ let executionContextId: number | undefined;
1074
+ let cancellationPromise: Promise<void> | undefined;
1075
+ let terminationPromise: Promise<void> | undefined;
1076
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
1077
+ let evaluationTimedOut = false;
1078
+ const terminateEvaluation = (): void => {
1079
+ terminationPromise ??= cdpSession.send('Runtime.terminateExecution').then(() => undefined);
1080
+ };
1081
+ const cancelEvaluation = (): void => {
1082
+ if (executionContextId === undefined) {
1083
+ return;
1084
+ }
1085
+ cancellationPromise ??= cdpSession.send('Runtime.evaluate', {
1086
+ expression: `globalThis[${JSON.stringify(evaluationCancelKey)}]?.(${JSON.stringify(cancellationKey)})`,
1087
+ contextId: executionContextId,
1088
+ returnByValue: true,
1089
+ awaitPromise: false,
1090
+ includeCommandLineAPI: false,
1091
+ userGesture: false,
1092
+ disableBreaks: true,
1093
+ }).then(() => undefined);
1094
+ };
1095
+ signal.addEventListener('abort', cancelEvaluation, { once: true });
1096
+ let evaluationError: unknown;
1097
+ let evaluationFailed = false;
1098
+ try {
1099
+ if (signal.aborted) {
1100
+ throw normalizeAbortReason(signal);
1101
+ }
1102
+ executionContextId = tab.evaluationExecutionContextId;
1103
+ if (executionContextId === undefined) {
1104
+ const frameTreeResponse = await cdpSession.send('Page.getFrameTree');
1105
+ if (signal.aborted) {
1106
+ throw normalizeAbortReason(signal);
1107
+ }
1108
+ const isolatedWorld = await cdpSession.send('Page.createIsolatedWorld', {
1109
+ frameId: frameTreeResponse.frameTree.frame.id,
1110
+ worldName: 'smartpuppeteer-evaluation',
1111
+ grantUniveralAccess: false,
1112
+ });
1113
+ executionContextId = isolatedWorld.executionContextId;
1114
+ tab.evaluationExecutionContextId = executionContextId;
1115
+ }
1116
+ if (signal.aborted) {
1117
+ throw normalizeAbortReason(signal);
1118
+ }
1119
+ const bootstrapResponse = await cdpSession.send('Runtime.evaluate', {
1120
+ expression: this.createEvaluationBootstrapExpression(),
1121
+ contextId: executionContextId,
1122
+ returnByValue: true,
1123
+ awaitPromise: true,
1124
+ includeCommandLineAPI: false,
1125
+ userGesture: false,
1126
+ disableBreaks: true,
1127
+ });
1128
+ if (bootstrapResponse.exceptionDetails) {
1129
+ throw new Error(this.readCdpExceptionMessage(bootstrapResponse.exceptionDetails));
1130
+ }
1131
+ if (signal.aborted) {
1132
+ throw normalizeAbortReason(signal);
1133
+ }
1134
+ timeoutHandle = setTimeout(() => {
1135
+ evaluationTimedOut = true;
1136
+ terminateEvaluation();
1137
+ }, evaluationOptions.timeoutMs + 250);
1138
+ const evaluationResponse = await cdpSession.send('Runtime.evaluate', {
1139
+ expression: evaluationExpression,
1140
+ contextId: executionContextId,
1141
+ returnByValue: true,
1142
+ awaitPromise: true,
1143
+ timeout: evaluationOptions.timeoutMs + 250,
1144
+ includeCommandLineAPI: false,
1145
+ userGesture: false,
1146
+ disableBreaks: true,
1147
+ allowUnsafeEvalBlockedByCSP: true,
1148
+ });
1149
+ if (evaluationTimedOut) {
1150
+ throw new Error(`Evaluation timed out after ${evaluationOptions.timeoutMs}ms`);
1151
+ }
1152
+ if (signal.aborted) {
1153
+ throw normalizeAbortReason(signal);
1154
+ }
1155
+ if (evaluationResponse.exceptionDetails) {
1156
+ throw new Error(this.readCdpExceptionMessage(evaluationResponse.exceptionDetails));
1157
+ }
1158
+ const envelope = evaluationResponse.result.value as IEvaluationEnvelope | undefined;
1159
+ if (!envelope || typeof envelope !== 'object' || typeof envelope.ok !== 'boolean') {
1160
+ throw new Error('Evaluation returned an invalid result envelope');
1161
+ }
1162
+ if (!envelope.ok) {
1163
+ throw new Error(
1164
+ typeof envelope.error === 'string'
1165
+ ? truncate(envelope.error, 2048)
1166
+ : 'Evaluation failed',
1167
+ );
1168
+ }
1169
+ if (typeof envelope.json !== 'string') {
1170
+ throw new Error('Evaluation returned an invalid JSON result');
1171
+ }
1172
+ if (new TextEncoder().encode(envelope.json).byteLength > evaluationOptions.maxOutputBytes) {
1173
+ throw new Error('Evaluation result exceeded maxOutputBytes during transfer');
1174
+ }
1175
+ return JSON.parse(envelope.json) as TLiveBrowserJsonValue;
1176
+ } catch (error) {
1177
+ evaluationError = error;
1178
+ evaluationFailed = true;
1179
+ throw error;
1180
+ } finally {
1181
+ signal.removeEventListener('abort', cancelEvaluation);
1182
+ if (timeoutHandle) {
1183
+ clearTimeout(timeoutHandle);
1184
+ }
1185
+ const cleanupErrors: unknown[] = [];
1186
+ if (cancellationPromise) {
1187
+ try {
1188
+ await cancellationPromise;
1189
+ } catch (error) {
1190
+ cleanupErrors.push(error);
1191
+ }
1192
+ }
1193
+ if (terminationPromise) {
1194
+ try {
1195
+ await terminationPromise;
1196
+ } catch (error) {
1197
+ cleanupErrors.push(error);
1198
+ }
1199
+ }
1200
+ if (executionContextId !== undefined && !cdpSession.detached) {
1201
+ try {
1202
+ await cdpSession.send('Runtime.evaluate', {
1203
+ expression: `globalThis[${JSON.stringify(evaluationCleanupKey)}]?.(${JSON.stringify(cancellationKey)})`,
1204
+ contextId: executionContextId,
1205
+ returnByValue: true,
1206
+ awaitPromise: false,
1207
+ includeCommandLineAPI: false,
1208
+ userGesture: false,
1209
+ disableBreaks: true,
1210
+ });
1211
+ } catch {
1212
+ // Navigation destroys the old execution context and its cancellation registry.
1213
+ }
1214
+ }
1215
+ if (!cdpSession.detached) {
1216
+ try {
1217
+ await cdpSession.detach();
1218
+ } catch (error) {
1219
+ cleanupErrors.push(error);
1220
+ }
1221
+ }
1222
+ if (cleanupErrors.length > 0) {
1223
+ throw new AggregateError(
1224
+ evaluationFailed ? [evaluationError, ...cleanupErrors] : cleanupErrors,
1225
+ 'Evaluation cleanup was incomplete',
1226
+ );
1227
+ }
1228
+ }
1229
+ }, operationOptions);
1230
+ }
1231
+
1232
+ public async click(
1233
+ optionsArg: ILiveBrowserClickOptions,
1234
+ operationOptions: ILiveBrowserOperationOptions = {},
1235
+ ): Promise<void> {
1236
+ const selector = validateBoundedString(
1237
+ optionsArg.selector,
1238
+ 'selector',
1239
+ 1,
1240
+ maxSelectorLength,
1241
+ );
1242
+ const timeout = this.validateTimeout(optionsArg.timeoutMs);
1243
+ const allowedButtons = ['left', 'middle', 'right'];
1244
+ if (optionsArg.button !== undefined && !allowedButtons.includes(optionsArg.button)) {
1245
+ throw new Error('click button is invalid');
1246
+ }
1247
+ const clickCount = optionsArg.clickCount === undefined
1248
+ ? undefined
1249
+ : validateInteger(optionsArg.clickCount, 'clickCount', 1, 3);
1250
+ return this.enqueuePublicOperation(async (signal) => {
1251
+ const tab = this.requireSemanticActionTarget(optionsArg);
1252
+ await this.ensureTabViewport(tab);
1253
+ const actionTab = this.requireSemanticActionTarget(optionsArg);
1254
+ await actionTab.page.locator(selector).setTimeout(timeout).click({
1255
+ button: optionsArg.button,
1256
+ count: clickCount,
1257
+ signal,
1258
+ });
1259
+ await this.refreshTab(actionTab);
1260
+ this.emitState();
1261
+ }, operationOptions);
1262
+ }
1263
+
1264
+ public async fill(
1265
+ optionsArg: ILiveBrowserFillOptions,
1266
+ operationOptions: ILiveBrowserOperationOptions = {},
1267
+ ): Promise<void> {
1268
+ const selector = validateBoundedString(
1269
+ optionsArg.selector,
1270
+ 'selector',
1271
+ 1,
1272
+ maxSelectorLength,
1273
+ );
1274
+ const text = validateBoundedString(optionsArg.text, 'text', 0, maxTextLength);
1275
+ const timeout = this.validateTimeout(optionsArg.timeoutMs);
1276
+ return this.enqueuePublicOperation(async (signal) => {
1277
+ const tab = this.requireSemanticActionTarget(optionsArg);
1278
+ await this.ensureTabViewport(tab);
1279
+ const actionTab = this.requireSemanticActionTarget(optionsArg);
1280
+ await actionTab.page.locator(selector).setTimeout(timeout).fill(text, { signal });
1281
+ await this.refreshTab(actionTab);
1282
+ this.emitState();
1283
+ }, operationOptions);
1284
+ }
1285
+
1286
+ public async press(
1287
+ optionsArg: ILiveBrowserPressOptions,
1288
+ operationOptions: ILiveBrowserOperationOptions = {},
1289
+ ): Promise<void> {
1290
+ const selector = validateBoundedString(
1291
+ optionsArg.selector,
1292
+ 'selector',
1293
+ 1,
1294
+ maxSelectorLength,
1295
+ );
1296
+ const key = validateBoundedString(optionsArg.key, 'key', 1, 64);
1297
+ const timeout = this.validateTimeout(optionsArg.timeoutMs);
1298
+ return this.enqueuePublicOperation(async (signal) => {
1299
+ const tab = this.requireSemanticActionTarget(optionsArg);
1300
+ await this.ensureTabViewport(tab);
1301
+ const actionTab = this.requireSemanticActionTarget(optionsArg);
1302
+ const element = await actionTab.page.waitForSelector(selector, {
1303
+ visible: true,
1304
+ timeout,
1305
+ signal,
1306
+ });
1307
+ if (!element) {
1308
+ throw new Error(`No visible element matched selector: ${selector}`);
1309
+ }
1310
+ try {
1311
+ await element.press(key as plugins.puppeteer.KeyInput);
1312
+ } finally {
1313
+ await element.dispose();
1314
+ }
1315
+ await this.refreshTab(actionTab);
1316
+ this.emitState();
1317
+ }, operationOptions);
1318
+ }
1319
+
1320
+ private enqueuePublicOperation<T>(
1321
+ operation: (signal: AbortSignal) => Promise<T>,
1322
+ operationOptions: ILiveBrowserOperationOptions = {},
1323
+ ): Promise<T> {
1324
+ const callerSignal = operationOptions.signal;
1325
+ if (
1326
+ callerSignal !== undefined
1327
+ && (
1328
+ typeof callerSignal !== 'object'
1329
+ || typeof callerSignal.addEventListener !== 'function'
1330
+ || typeof callerSignal.removeEventListener !== 'function'
1331
+ )
1332
+ ) {
1333
+ return Promise.reject(new Error('operationOptions.signal must be an AbortSignal'));
1334
+ }
1335
+ if (callerSignal?.aborted) {
1336
+ return Promise.reject(normalizeAbortReason(callerSignal));
1337
+ }
1338
+ if (
1339
+ this.status === 'stopping'
1340
+ || this.normalStopRequested
1341
+ || (this.status === 'stopped' && this.operationRunning)
1342
+ ) {
1343
+ return Promise.reject(new Error('LiveBrowserSession is stopping'));
1344
+ }
1345
+ if (this.admittedPublicOperations >= maxQueuedPublicOperations) {
1346
+ return Promise.reject(new Error('LiveBrowserSession operation queue is full'));
1347
+ }
1348
+ this.admittedPublicOperations += 1;
1349
+ return this.enqueueQueuedOperation('public', operation, false, callerSignal);
1350
+ }
1351
+
1352
+ private enqueueInternalOperation(
1353
+ operation: (signal: AbortSignal) => Promise<void>,
1354
+ priority = false,
1355
+ ): Promise<void> {
1356
+ if (this.status === 'stopped' || this.status === 'stopping') {
1357
+ return Promise.reject(new Error('LiveBrowserSession is stopping'));
1358
+ }
1359
+ this.admittedInternalOperations += 1;
1360
+ return this.enqueueQueuedOperation('internal', operation, priority);
1361
+ }
1362
+
1363
+ private enqueueQueuedOperation<T>(
1364
+ kind: TQueuedOperationKind,
1365
+ operation: (signal: AbortSignal) => Promise<T>,
1366
+ priority = false,
1367
+ callerSignal?: AbortSignal,
1368
+ ): Promise<T> {
1369
+ return new Promise<T>((resolve, reject) => {
1370
+ const queuedOperation: IQueuedOperation = {
1371
+ kind,
1372
+ state: 'queued',
1373
+ controller: new AbortController(),
1374
+ run: operation,
1375
+ resolve: (value) => resolve(value as T),
1376
+ reject,
1377
+ capacityReleased: false,
1378
+ };
1379
+ if (callerSignal) {
1380
+ const onCallerAbort = (): void => {
1381
+ const abortReason = normalizeAbortReason(callerSignal);
1382
+ if (queuedOperation.state === 'queued') {
1383
+ const operationIndex = this.operationQueue.indexOf(queuedOperation);
1384
+ if (operationIndex >= 0) {
1385
+ this.operationQueue.splice(operationIndex, 1);
1386
+ }
1387
+ queuedOperation.controller.abort(abortReason);
1388
+ queuedOperation.reject(abortReason);
1389
+ this.finalizeQueuedOperation(queuedOperation);
1390
+ this.drainOperationQueue();
1391
+ } else if (queuedOperation.state === 'active') {
1392
+ queuedOperation.controller.abort(abortReason);
1393
+ }
1394
+ };
1395
+ callerSignal.addEventListener('abort', onCallerAbort, { once: true });
1396
+ queuedOperation.removeCallerAbortListener = () => {
1397
+ callerSignal.removeEventListener('abort', onCallerAbort);
1398
+ };
1399
+ if (callerSignal.aborted) {
1400
+ onCallerAbort();
1401
+ }
1402
+ }
1403
+ if (queuedOperation.state !== 'queued') {
1404
+ return;
1405
+ }
1406
+ if (priority) {
1407
+ this.operationQueue.unshift(queuedOperation);
1408
+ } else {
1409
+ this.operationQueue.push(queuedOperation);
1410
+ }
1411
+ this.drainOperationQueue();
1412
+ });
1413
+ }
1414
+
1415
+ private drainOperationQueue(): void {
1416
+ if (this.operationRunning) {
1417
+ return;
1418
+ }
1419
+ const queuedOperation = this.operationQueue.shift();
1420
+ if (!queuedOperation) {
1421
+ return;
1422
+ }
1423
+ this.operationRunning = true;
1424
+ this.activeOperation = queuedOperation;
1425
+ queuedOperation.state = 'active';
1426
+ void this.executeQueuedOperation(queuedOperation).catch((error) => {
1427
+ this.operationRunning = false;
1428
+ this.activeOperation = undefined;
1429
+ this.emitError({
1430
+ code: 'operation_scheduler_failed',
1431
+ message: normalizeErrorMessage(error),
1432
+ fatal: true,
1433
+ });
1434
+ this.drainOperationQueue();
1435
+ });
1436
+ }
1437
+
1438
+ private async executeQueuedOperation(queuedOperation: IQueuedOperation): Promise<void> {
1439
+ try {
1440
+ if (queuedOperation.controller.signal.aborted) {
1441
+ throw queuedOperation.controller.signal.reason;
1442
+ }
1443
+ const value = await queuedOperation.run(queuedOperation.controller.signal);
1444
+ if (queuedOperation.controller.signal.aborted) {
1445
+ throw queuedOperation.controller.signal.reason;
1446
+ }
1447
+ queuedOperation.resolve(value);
1448
+ } catch (error) {
1449
+ queuedOperation.reject(error);
1450
+ } finally {
1451
+ this.finalizeQueuedOperation(queuedOperation);
1452
+ if (this.activeOperation === queuedOperation) {
1453
+ this.activeOperation = undefined;
1454
+ }
1455
+ this.operationRunning = false;
1456
+ if (
1457
+ this.status === 'stopped'
1458
+ && !this.operationQueue.some((operation) => operation.kind === 'shutdown')
1459
+ ) {
1460
+ this.normalStopRequested = false;
1461
+ }
1462
+ this.drainOperationQueue();
1463
+ }
1464
+ }
1465
+
1466
+ private finalizeQueuedOperation(queuedOperation: IQueuedOperation): void {
1467
+ if (queuedOperation.state === 'settled') {
1468
+ return;
1469
+ }
1470
+ queuedOperation.state = 'settled';
1471
+ queuedOperation.removeCallerAbortListener?.();
1472
+ queuedOperation.removeCallerAbortListener = undefined;
1473
+ if (queuedOperation.capacityReleased) {
1474
+ return;
1475
+ }
1476
+ queuedOperation.capacityReleased = true;
1477
+ if (queuedOperation.kind === 'public') {
1478
+ this.admittedPublicOperations -= 1;
1479
+ } else if (queuedOperation.kind === 'internal') {
1480
+ this.admittedInternalOperations -= 1;
1481
+ }
1482
+ }
1483
+
1484
+ private cancelQueuedOperations(error: Error): void {
1485
+ for (const queuedOperation of this.operationQueue.splice(0)) {
1486
+ queuedOperation.controller.abort(error);
1487
+ queuedOperation.reject(error);
1488
+ this.finalizeQueuedOperation(queuedOperation);
1489
+ }
1490
+ }
1491
+
1492
+ private beginShutdown(abortActiveOperation: boolean): void {
1493
+ if (this.status !== 'stopped' && this.status !== 'stopping') {
1494
+ this.status = 'stopping';
1495
+ this.emitState();
1496
+ }
1497
+ const shutdownError = new Error('LiveBrowserSession is stopping');
1498
+ if (
1499
+ abortActiveOperation
1500
+ && this.activeOperation
1501
+ && this.activeOperation.kind !== 'shutdown'
1502
+ ) {
1503
+ this.activeOperation.controller.abort(shutdownError);
1504
+ }
1505
+ if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
1506
+ this.browserLifetimeController.abort(shutdownError);
1507
+ }
1508
+ this.cancelQueuedOperations(shutdownError);
1509
+ }
1510
+
1511
+ private requestShutdown(error?: ILiveBrowserError): Promise<void> {
1512
+ if (this.status === 'stopped') {
1513
+ return Promise.resolve();
1514
+ }
1515
+ if (this.shutdownPromise) {
1516
+ return this.shutdownPromise;
1517
+ }
1518
+ this.beginShutdown(true);
1519
+ const shutdownPromise = this.enqueueQueuedOperation(
1520
+ 'shutdown',
1521
+ async () => this.stopInternal(error),
1522
+ true,
1523
+ );
1524
+ this.shutdownPromise = shutdownPromise;
1525
+ void shutdownPromise.then(
1526
+ () => {
1527
+ if (this.shutdownPromise === shutdownPromise) {
1528
+ this.shutdownPromise = undefined;
1529
+ }
1530
+ },
1531
+ () => {
1532
+ if (this.shutdownPromise === shutdownPromise) {
1533
+ this.shutdownPromise = undefined;
1534
+ }
1535
+ },
1536
+ );
1537
+ return shutdownPromise;
1538
+ }
1539
+
1540
+ private scheduleOperation(
1541
+ operation: (signal: AbortSignal) => Promise<void>,
1542
+ errorCode: string,
1543
+ tabId?: string,
1544
+ priority = false,
1545
+ ): boolean {
1546
+ if (this.admittedInternalOperations >= maxQueuedInternalOperations) {
1547
+ const queueError = new Error('LiveBrowserSession internal operation queue is full');
1548
+ const liveBrowserError: ILiveBrowserError = {
1549
+ code: 'internal_operation_queue_full',
1550
+ message: queueError.message,
1551
+ fatal: priority,
1552
+ ...(tabId ? { tabId } : {}),
1553
+ };
1554
+ this.emitError(liveBrowserError);
1555
+ if (priority) {
1556
+ void this.requestShutdown(liveBrowserError).catch((error) => {
1557
+ this.emitError({
1558
+ code: 'queue_overflow_shutdown_failed',
1559
+ message: normalizeErrorMessage(error),
1560
+ fatal: true,
1561
+ });
1562
+ });
1563
+ }
1564
+ return false;
1565
+ }
1566
+ void this.enqueueInternalOperation(operation, priority).catch((error) => {
1567
+ if (
1568
+ this.status === 'stopped'
1569
+ || this.status === 'stopping'
1570
+ || this.normalStopRequested
1571
+ ) {
1572
+ return;
1573
+ }
1574
+ this.emitError({
1575
+ code: errorCode,
1576
+ message: normalizeErrorMessage(error),
1577
+ fatal: false,
1578
+ ...(tabId ? { tabId } : {}),
1579
+ });
1580
+ });
1581
+ return true;
1582
+ }
1583
+
1584
+ private emitEvent(event: TLiveBrowserEvent): void {
1585
+ for (const listener of [...this.eventListeners]) {
1586
+ try {
1587
+ listener(event);
1588
+ } catch {
1589
+ // A consumer listener must not interrupt browser lifecycle cleanup.
1590
+ }
1591
+ }
1592
+ }
1593
+
1594
+ private emitState(): void {
1595
+ this.emitEvent({
1596
+ type: 'state',
1597
+ state: this.getState(),
1598
+ });
1599
+ }
1600
+
1601
+ private emitError(error: ILiveBrowserError): void {
1602
+ if (error.fatal) {
1603
+ this.lastError = { ...error };
1604
+ }
1605
+ this.emitEvent({
1606
+ type: 'error',
1607
+ error: { ...error },
1608
+ });
1609
+ this.emitState();
1610
+ }
1611
+
1612
+ private createTabState(tab: IPrivateLiveBrowserTab): ILiveBrowserTabState {
1613
+ return {
1614
+ id: tab.id,
1615
+ url: tab.url,
1616
+ title: tab.title,
1617
+ active: this.activeTabId === tab.id,
1618
+ status: tab.status,
1619
+ generation: tab.generation,
1620
+ appliedViewportRevision: tab.appliedViewportRevision,
1621
+ streaming: tab.streaming,
1622
+ };
1623
+ }
1624
+
1625
+ private async stopInternal(error?: ILiveBrowserError): Promise<void> {
1626
+ if (this.status === 'stopped') {
1627
+ return;
1628
+ }
1629
+ this.status = 'stopping';
1630
+ if (error) {
1631
+ this.lastError = { ...error };
1632
+ }
1633
+ this.emitState();
1634
+ if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
1635
+ this.browserLifetimeController.abort(new Error('LiveBrowserSession is stopping'));
1636
+ }
1637
+
1638
+ const browser = this.browser;
1639
+ if (browser && this.browserDisconnectedListener) {
1640
+ browser.off('disconnected', this.browserDisconnectedListener);
1641
+ }
1642
+ this.browserDisconnectedListener = undefined;
1643
+
1644
+ const shutdownErrors: unknown[] = [];
1645
+ for (const tab of [...this.tabs.values()]) {
1646
+ tab.closing = true;
1647
+ const securityCdpSession = tab.securityCdpSession;
1648
+ tab.securityCdpSession = undefined;
1649
+ if (securityCdpSession && !securityCdpSession.detached) {
1650
+ try {
1651
+ await securityCdpSession.detach();
1652
+ } catch {
1653
+ // Browser lifetime cancellation may close the target before explicit detach settles.
1654
+ }
1655
+ }
1656
+ try {
1657
+ await this.stopScreencast(tab);
1658
+ } catch (cleanupError) {
1659
+ shutdownErrors.push(cleanupError);
1660
+ }
1661
+ try {
1662
+ this.removePageListeners(tab);
1663
+ } catch (cleanupError) {
1664
+ shutdownErrors.push(cleanupError);
1665
+ }
1666
+ }
1667
+ try {
1668
+ await this.retireOutstandingFrames(() => true);
1669
+ } catch (cleanupError) {
1670
+ shutdownErrors.push(cleanupError);
1671
+ }
1672
+
1673
+ if (browser) {
1674
+ try {
1675
+ await browser.close();
1676
+ } catch (closeError) {
1677
+ shutdownErrors.push(closeError);
1678
+ }
1679
+ }
1680
+
1681
+ for (const tab of this.tabs.values()) {
1682
+ this.tabIdsByPage.delete(tab.page);
1683
+ }
1684
+ this.tabs.clear();
1685
+ this.outstandingFrames.clear();
1686
+ this.browser = undefined;
1687
+ this.browserContext = undefined;
1688
+ this.browserLifetimeController = undefined;
1689
+ this.activeTabId = null;
1690
+ this.status = 'stopped';
1691
+ this.emitState();
1692
+ if (shutdownErrors.length > 0 && !error) {
1693
+ throw new AggregateError(shutdownErrors, 'LiveBrowserSession shutdown was incomplete');
1694
+ }
1695
+ }
1696
+
1697
+ private async configureBrowserSecurity(
1698
+ browser: plugins.puppeteer.Browser,
1699
+ ): Promise<void> {
1700
+ if (!this.options.security?.denyPermissions) {
1701
+ return;
1702
+ }
1703
+ const cdpSession = await browser.target().createCDPSession();
1704
+ try {
1705
+ await cdpSession.send('Browser.grantPermissions', { permissions: [] });
1706
+ } finally {
1707
+ if (!cdpSession.detached) {
1708
+ await cdpSession.detach();
1709
+ }
1710
+ }
1711
+ }
1712
+
1713
+ private requireBrowserContext(): plugins.puppeteer.BrowserContext {
1714
+ if (this.status !== 'running' || !this.browserContext) {
1715
+ throw new Error('LiveBrowserSession is not running');
1716
+ }
1717
+ return this.browserContext;
1718
+ }
1719
+
1720
+ private requireTab(tabId: string): IPrivateLiveBrowserTab {
1721
+ validateBoundedString(tabId, 'tabId', 1, 128);
1722
+ const tab = this.tabs.get(tabId);
1723
+ if (!tab) {
1724
+ throw new Error(`Unknown tab: ${tabId}`);
1725
+ }
1726
+ return tab;
1727
+ }
1728
+
1729
+ private requireActiveTab(): IPrivateLiveBrowserTab {
1730
+ if (this.status !== 'running' || !this.activeTabId) {
1731
+ throw new Error('LiveBrowserSession has no active tab');
1732
+ }
1733
+ const tab = this.requireTab(this.activeTabId);
1734
+ if (tab.status !== 'open' || tab.page.isClosed()) {
1735
+ throw new Error(`Active tab is not available: ${tab.id}`);
1736
+ }
1737
+ return tab;
1738
+ }
1739
+
1740
+ private resolveActionTab(tabId?: string): IPrivateLiveBrowserTab {
1741
+ if (this.status !== 'running') {
1742
+ throw new Error('LiveBrowserSession is not running');
1743
+ }
1744
+ const tab = tabId ? this.requireTab(tabId) : this.requireActiveTab();
1745
+ if (tab.status !== 'open' || tab.page.isClosed()) {
1746
+ throw new Error(`Tab is not available: ${tab.id}`);
1747
+ }
1748
+ return tab;
1749
+ }
1750
+
1751
+ private resolveNavigationTab(tabId?: string): IPrivateLiveBrowserTab {
1752
+ return this.resolveActionTab(tabId);
1753
+ }
1754
+
1755
+ private async ensureTabViewport(tab: IPrivateLiveBrowserTab): Promise<void> {
1756
+ if (tab.appliedViewportRevision === this.viewportRevision) {
1757
+ return;
1758
+ }
1759
+ const shouldRestartScreencast = this.activeTabId === tab.id && tab.streaming;
1760
+ if (shouldRestartScreencast) {
1761
+ await this.stopScreencast(tab);
1762
+ }
1763
+ try {
1764
+ await tab.page.setViewport(createPuppeteerViewport(this.viewport));
1765
+ tab.appliedViewportRevision = this.viewportRevision;
1766
+ } finally {
1767
+ if (
1768
+ shouldRestartScreencast
1769
+ && this.status === 'running'
1770
+ && this.activeTabId === tab.id
1771
+ && tab.status === 'open'
1772
+ && !tab.page.isClosed()
1773
+ ) {
1774
+ await this.startScreencast(tab);
1775
+ }
1776
+ }
1777
+ }
1778
+
1779
+ private async registerPage(page: plugins.puppeteer.Page): Promise<IPrivateLiveBrowserTab> {
1780
+ const existingTabId = this.tabIdsByPage.get(page);
1781
+ if (existingTabId) {
1782
+ return this.requireTab(existingTabId);
1783
+ }
1784
+ if (page.isClosed()) {
1785
+ throw new Error('Cannot register a closed page');
1786
+ }
1787
+
1788
+ const tab: IPrivateLiveBrowserTab = {
1789
+ id: `tab-${++this.tabSequence}`,
1790
+ page,
1791
+ url: truncate(page.url(), 4096),
1792
+ title: '',
1793
+ status: 'open',
1794
+ generation: 0,
1795
+ appliedViewportRevision: 0,
1796
+ streaming: false,
1797
+ streamInvalidated: true,
1798
+ navigationInProgress: false,
1799
+ closing: false,
1800
+ stateUpdateQueued: false,
1801
+ stateUpdatePending: false,
1802
+ navigationResetPending: false,
1803
+ removeListeners: [],
1804
+ };
1805
+ this.tabs.set(tab.id, tab);
1806
+ this.tabIdsByPage.set(page, tab.id);
1807
+ try {
1808
+ if (this.options.security?.denyFileChoosers) {
1809
+ const securityCdpSession = await page.createCDPSession();
1810
+ tab.securityCdpSession = securityCdpSession;
1811
+ await securityCdpSession.send('Page.enable', {
1812
+ enableFileChooserOpenedEvent: true,
1813
+ });
1814
+ await securityCdpSession.send('Page.setInterceptFileChooserDialog', {
1815
+ enabled: true,
1816
+ cancel: true,
1817
+ });
1818
+ }
1819
+ await this.ensureTabViewport(tab);
1820
+ await this.refreshTab(tab);
1821
+
1822
+ const onPopup = (popup: plugins.puppeteer.Page | null): void => {
1823
+ if (
1824
+ !popup
1825
+ || this.normalStopRequested
1826
+ || this.status === 'stopped'
1827
+ || this.status === 'stopping'
1828
+ ) {
1829
+ return;
1830
+ }
1831
+ const popupWasScheduled = this.scheduleOperation(async () => {
1832
+ if (popup.isClosed()) {
1833
+ return;
1834
+ }
1835
+ const previousActiveTabId = this.activeTabId;
1836
+ let popupTab: IPrivateLiveBrowserTab | undefined;
1837
+ try {
1838
+ popupTab = await this.registerPage(popup);
1839
+ await this.activateTabInternal(popupTab.id);
1840
+ } catch (error) {
1841
+ const rollbackError = await this.rollbackCreatedPage(
1842
+ popup,
1843
+ popupTab,
1844
+ previousActiveTabId,
1845
+ );
1846
+ if (rollbackError) {
1847
+ throw new AggregateError([error, rollbackError], 'Popup registration rollback failed');
1848
+ }
1849
+ throw error;
1850
+ }
1851
+ }, 'popup_registration_failed', tab.id);
1852
+ if (!popupWasScheduled) {
1853
+ const popupError: ILiveBrowserError = {
1854
+ code: 'popup_registration_capacity_exceeded',
1855
+ message: 'A popup could not be admitted to the internal operation queue',
1856
+ fatal: true,
1857
+ tabId: tab.id,
1858
+ };
1859
+ this.emitError(popupError);
1860
+ void this.requestShutdown(popupError).catch((shutdownError) => {
1861
+ this.emitError({
1862
+ code: 'untracked_popup_shutdown_failed',
1863
+ message: normalizeErrorMessage(shutdownError),
1864
+ fatal: true,
1865
+ tabId: tab.id,
1866
+ });
1867
+ });
1868
+ }
1869
+ };
1870
+ const onFrameNavigated = (frame: plugins.puppeteer.Frame): void => {
1871
+ if (frame !== page.mainFrame() || tab.closing) {
1872
+ return;
1873
+ }
1874
+ tab.evaluationExecutionContextId = undefined;
1875
+ const navigationReset = !tab.navigationInProgress;
1876
+ if (navigationReset) {
1877
+ tab.streamInvalidated = true;
1878
+ this.retireFramesForTabInBackground(tab.id);
1879
+ }
1880
+ this.requestTabStateUpdate(tab, navigationReset);
1881
+ };
1882
+ const onLoad = (): void => {
1883
+ this.requestTabStateUpdate(tab, false);
1884
+ };
1885
+ const onClose = (): void => {
1886
+ tab.streamInvalidated = true;
1887
+ this.retireFramesForTabInBackground(tab.id);
1888
+ if (tab.closing || this.normalStopRequested) {
1889
+ return;
1890
+ }
1891
+ this.scheduleOperation(async () => {
1892
+ await this.handleUnexpectedPageClose(tab);
1893
+ }, 'page_close_cleanup_failed', tab.id, true);
1894
+ };
1895
+ const onCrash = (error: Error): void => {
1896
+ tab.streamInvalidated = true;
1897
+ this.retireFramesForTabInBackground(tab.id);
1898
+ if (tab.closing || this.normalStopRequested) {
1899
+ return;
1900
+ }
1901
+ this.scheduleOperation(async () => {
1902
+ if (!this.tabs.has(tab.id) || tab.status === 'crashed') {
1903
+ return;
1904
+ }
1905
+ await this.stopScreencast(tab);
1906
+ tab.status = 'crashed';
1907
+ const wasActive = this.activeTabId === tab.id;
1908
+ if (wasActive) {
1909
+ this.activeTabId = null;
1910
+ }
1911
+ this.emitError({
1912
+ code: 'page_crashed',
1913
+ message: normalizeErrorMessage(error),
1914
+ fatal: false,
1915
+ tabId: tab.id,
1916
+ });
1917
+ if (wasActive) {
1918
+ await this.activateReplacementTab();
1919
+ }
1920
+ }, 'page_crash_cleanup_failed', tab.id, true);
1921
+ };
1922
+ const onPageError = (error: unknown): void => {
1923
+ if (tab.closing || this.normalStopRequested) {
1924
+ return;
1925
+ }
1926
+ this.emitError({
1927
+ code: 'page_error',
1928
+ message: normalizeErrorMessage(error),
1929
+ fatal: false,
1930
+ tabId: tab.id,
1931
+ });
1932
+ };
1933
+
1934
+ page.on('popup', onPopup);
1935
+ page.on('framenavigated', onFrameNavigated);
1936
+ page.on('load', onLoad);
1937
+ page.on('close', onClose);
1938
+ page.on('error', onCrash);
1939
+ page.on('pageerror', onPageError);
1940
+ tab.removeListeners.push(
1941
+ () => page.off('popup', onPopup),
1942
+ () => page.off('framenavigated', onFrameNavigated),
1943
+ () => page.off('load', onLoad),
1944
+ () => page.off('close', onClose),
1945
+ () => page.off('error', onCrash),
1946
+ () => page.off('pageerror', onPageError),
1947
+ );
1948
+ return tab;
1949
+ } catch (error) {
1950
+ if (tab.securityCdpSession && !tab.securityCdpSession.detached) {
1951
+ try {
1952
+ await tab.securityCdpSession.detach();
1953
+ } catch {
1954
+ // The page may have closed while security setup was failing.
1955
+ }
1956
+ }
1957
+ this.removePageListeners(tab);
1958
+ this.tabs.delete(tab.id);
1959
+ this.tabIdsByPage.delete(page);
1960
+ throw error;
1961
+ }
1962
+ }
1963
+
1964
+ private removePageListeners(tab: IPrivateLiveBrowserTab): void {
1965
+ for (const removeListener of tab.removeListeners.splice(0)) {
1966
+ removeListener();
1967
+ }
1968
+ }
1969
+
1970
+ private retireFramesForTabInBackground(tabId: string): void {
1971
+ void this.retireOutstandingFrames((frame) => frame.tabId === tabId).catch((error) => {
1972
+ if (this.status === 'running' && !this.normalStopRequested) {
1973
+ this.emitError({
1974
+ code: 'frame_retirement_failed',
1975
+ message: normalizeErrorMessage(error),
1976
+ fatal: false,
1977
+ tabId,
1978
+ });
1979
+ }
1980
+ });
1981
+ }
1982
+
1983
+ private requestTabStateUpdate(
1984
+ tab: IPrivateLiveBrowserTab,
1985
+ navigationReset: boolean,
1986
+ ): void {
1987
+ if (!this.tabs.has(tab.id) || tab.closing) {
1988
+ return;
1989
+ }
1990
+ tab.stateUpdatePending = true;
1991
+ tab.navigationResetPending ||= navigationReset;
1992
+ if (tab.stateUpdateQueued) {
1993
+ return;
1994
+ }
1995
+ tab.stateUpdateQueued = true;
1996
+ const wasScheduled = this.scheduleOperation(async () => {
1997
+ try {
1998
+ const shouldResetNavigation = tab.navigationResetPending;
1999
+ tab.stateUpdatePending = false;
2000
+ tab.navigationResetPending = false;
2001
+ if (!this.tabs.has(tab.id) || tab.closing) {
2002
+ return;
2003
+ }
2004
+ if (shouldResetNavigation && this.activeTabId === tab.id) {
2005
+ await this.stopScreencast(tab);
2006
+ }
2007
+ await this.refreshTab(tab);
2008
+ this.emitState();
2009
+ if (
2010
+ shouldResetNavigation
2011
+ && this.activeTabId === tab.id
2012
+ && this.status === 'running'
2013
+ && tab.status === 'open'
2014
+ ) {
2015
+ await this.startScreencast(tab);
2016
+ }
2017
+ } finally {
2018
+ tab.stateUpdateQueued = false;
2019
+ if (
2020
+ tab.stateUpdatePending
2021
+ && this.tabs.has(tab.id)
2022
+ && this.status === 'running'
2023
+ && !tab.closing
2024
+ ) {
2025
+ this.requestTabStateUpdate(tab, false);
2026
+ }
2027
+ }
2028
+ }, 'page_state_update_failed', tab.id);
2029
+ if (!wasScheduled) {
2030
+ tab.stateUpdateQueued = false;
2031
+ }
2032
+ }
2033
+
2034
+ private async rollbackCreatedPage(
2035
+ page: plugins.puppeteer.Page,
2036
+ tab: IPrivateLiveBrowserTab | undefined,
2037
+ previousActiveTabId: string | null,
2038
+ ): Promise<Error | undefined> {
2039
+ const rollbackErrors: unknown[] = [];
2040
+ if (tab) {
2041
+ try {
2042
+ await this.stopScreencast(tab);
2043
+ } catch (error) {
2044
+ rollbackErrors.push(error);
2045
+ }
2046
+ tab.closing = true;
2047
+ }
2048
+ try {
2049
+ if (!page.isClosed()) {
2050
+ await page.close();
2051
+ }
2052
+ } catch (error) {
2053
+ rollbackErrors.push(error);
2054
+ }
2055
+ const pageClosed = page.isClosed();
2056
+ if (tab) {
2057
+ if (pageClosed) {
2058
+ this.removePageListeners(tab);
2059
+ this.tabs.delete(tab.id);
2060
+ this.tabIdsByPage.delete(tab.page);
2061
+ } else {
2062
+ tab.closing = false;
2063
+ try {
2064
+ await this.refreshTab(tab);
2065
+ } catch (error) {
2066
+ rollbackErrors.push(error);
2067
+ }
2068
+ }
2069
+ } else {
2070
+ this.tabIdsByPage.delete(page);
2071
+ if (!pageClosed) {
2072
+ const rollbackError: ILiveBrowserError = {
2073
+ code: 'untracked_page_rollback_failed',
2074
+ message: 'A failed page registration could not close its Chromium page',
2075
+ fatal: true,
2076
+ };
2077
+ if (!this.normalStopRequested && this.status === 'running') {
2078
+ this.emitError(rollbackError);
2079
+ }
2080
+ this.normalStopRequested = true;
2081
+ this.beginShutdown(false);
2082
+ try {
2083
+ await this.stopInternal();
2084
+ } catch (error) {
2085
+ rollbackErrors.push(error);
2086
+ }
2087
+ }
2088
+ }
2089
+
2090
+ const previousActiveTab = previousActiveTabId
2091
+ ? this.tabs.get(previousActiveTabId)
2092
+ : undefined;
2093
+ this.activeTabId = previousActiveTab?.id ?? null;
2094
+ if (
2095
+ previousActiveTab
2096
+ && previousActiveTab.status === 'open'
2097
+ && !previousActiveTab.page.isClosed()
2098
+ ) {
2099
+ try {
2100
+ await this.ensureTabViewport(previousActiveTab);
2101
+ await previousActiveTab.page.bringToFront();
2102
+ if (this.status === 'running' && !previousActiveTab.streaming) {
2103
+ await this.startScreencast(previousActiveTab);
2104
+ }
2105
+ } catch (error) {
2106
+ rollbackErrors.push(error);
2107
+ }
2108
+ }
2109
+ this.emitState();
2110
+ if (rollbackErrors.length === 0) {
2111
+ return undefined;
2112
+ }
2113
+ return new AggregateError(rollbackErrors, 'Failed to roll back a new tab');
2114
+ }
2115
+
2116
+ private async handleUnexpectedPageClose(tab: IPrivateLiveBrowserTab): Promise<void> {
2117
+ if (!this.tabs.has(tab.id)) {
2118
+ return;
2119
+ }
2120
+ await this.stopScreencast(tab);
2121
+ this.removePageListeners(tab);
2122
+ this.tabs.delete(tab.id);
2123
+ this.tabIdsByPage.delete(tab.page);
2124
+
2125
+ if (this.activeTabId === tab.id) {
2126
+ this.activeTabId = null;
2127
+ await this.activateReplacementTab();
2128
+ } else {
2129
+ this.emitState();
2130
+ }
2131
+ }
2132
+
2133
+ private async activateTabInternal(tabId: string): Promise<void> {
2134
+ const tab = this.requireTab(tabId);
2135
+ if (tab.status !== 'open' || tab.page.isClosed()) {
2136
+ throw new Error(`Tab is not available: ${tab.id}`);
2137
+ }
2138
+ if (this.activeTabId === tab.id && tab.streaming) {
2139
+ await tab.page.bringToFront();
2140
+ return;
2141
+ }
2142
+
2143
+ const currentTab = this.activeTabId ? this.tabs.get(this.activeTabId) : undefined;
2144
+ if (currentTab) {
2145
+ await this.stopScreencast(currentTab);
2146
+ }
2147
+ try {
2148
+ await this.ensureTabViewport(tab);
2149
+ await tab.page.bringToFront();
2150
+ this.activeTabId = tab.id;
2151
+ this.emitState();
2152
+ if (this.status === 'running') {
2153
+ await this.startScreencast(tab);
2154
+ }
2155
+ } catch (error) {
2156
+ if (currentTab && this.tabs.has(currentTab.id) && !currentTab.page.isClosed()) {
2157
+ this.activeTabId = currentTab.id;
2158
+ await this.ensureTabViewport(currentTab);
2159
+ await currentTab.page.bringToFront();
2160
+ if (this.status === 'running' && !currentTab.streaming) {
2161
+ await this.startScreencast(currentTab);
2162
+ }
2163
+ }
2164
+ this.emitState();
2165
+ throw error;
2166
+ }
2167
+ }
2168
+
2169
+ private async activateReplacementTab(): Promise<void> {
2170
+ const attemptedTabIds = new Set<string>();
2171
+ while (true) {
2172
+ const replacementTab = [...this.tabs.values()].find((tab) => (
2173
+ !attemptedTabIds.has(tab.id)
2174
+ && tab.status === 'open'
2175
+ && !tab.page.isClosed()
2176
+ ));
2177
+ if (!replacementTab) {
2178
+ this.activeTabId = null;
2179
+ this.normalStopRequested = true;
2180
+ this.beginShutdown(false);
2181
+ await this.stopInternal();
2182
+ return;
2183
+ }
2184
+
2185
+ attemptedTabIds.add(replacementTab.id);
2186
+ try {
2187
+ await this.activateTabInternal(replacementTab.id);
2188
+ return;
2189
+ } catch (error) {
2190
+ this.activeTabId = null;
2191
+ this.emitError({
2192
+ code: 'replacement_tab_activation_failed',
2193
+ message: normalizeErrorMessage(error),
2194
+ fatal: false,
2195
+ tabId: replacementTab.id,
2196
+ });
2197
+ }
2198
+ }
2199
+ }
2200
+
2201
+ private async navigateTab(
2202
+ tab: IPrivateLiveBrowserTab,
2203
+ navigation: () => Promise<void>,
2204
+ signal: AbortSignal,
2205
+ ): Promise<void> {
2206
+ const isActive = this.activeTabId === tab.id;
2207
+ tab.evaluationExecutionContextId = undefined;
2208
+ tab.navigationInProgress = true;
2209
+ if (isActive) {
2210
+ await this.stopScreencast(tab);
2211
+ }
2212
+ try {
2213
+ if (signal.aborted) {
2214
+ throw signal.reason;
2215
+ }
2216
+ await navigation();
2217
+ } finally {
2218
+ tab.navigationInProgress = false;
2219
+ if (this.tabs.has(tab.id) && !tab.page.isClosed()) {
2220
+ await this.refreshTab(tab);
2221
+ this.emitState();
2222
+ if (
2223
+ isActive
2224
+ && this.activeTabId === tab.id
2225
+ && this.status === 'running'
2226
+ && tab.status === 'open'
2227
+ ) {
2228
+ await this.startScreencast(tab);
2229
+ }
2230
+ }
2231
+ }
2232
+ }
2233
+
2234
+ private createPuppeteerNavigationOptions(
2235
+ options: ILiveBrowserNavigationOptions,
2236
+ signal: AbortSignal,
2237
+ ): plugins.puppeteer.WaitForOptions {
2238
+ return {
2239
+ timeout: this.validateTimeout(options.timeoutMs, 30000),
2240
+ waitUntil: this.validateWaitUntil(options.waitUntil),
2241
+ signal,
2242
+ };
2243
+ }
2244
+
2245
+ private async refreshTab(tab: IPrivateLiveBrowserTab): Promise<void> {
2246
+ if (tab.page.isClosed()) {
2247
+ return;
2248
+ }
2249
+ tab.url = truncate(tab.page.url(), 4096);
2250
+ try {
2251
+ tab.title = truncate(await tab.page.title(), 1024);
2252
+ } catch (error) {
2253
+ if (!tab.page.isClosed() && !tab.closing) {
2254
+ throw error;
2255
+ }
2256
+ }
2257
+ }
2258
+
2259
+ private async startScreencast(tab: IPrivateLiveBrowserTab): Promise<void> {
2260
+ if (
2261
+ this.status !== 'running'
2262
+ || this.activeTabId !== tab.id
2263
+ || tab.status !== 'open'
2264
+ || tab.page.isClosed()
2265
+ || tab.streaming
2266
+ ) {
2267
+ return;
2268
+ }
2269
+
2270
+ await this.ensureTabViewport(tab);
2271
+
2272
+ const cdpSession = await tab.page.createCDPSession();
2273
+ const generation = tab.generation + 1;
2274
+ const frameListener: TScreencastFrameListener = (event) => {
2275
+ this.handleScreencastFrame(tab, cdpSession, generation, event);
2276
+ };
2277
+ const cdpConnection = cdpSession.connection();
2278
+ const cdpSessionDetachedListener: TCdpSessionDetachedListener = (detachedSession) => {
2279
+ if (detachedSession !== cdpSession) {
2280
+ return;
2281
+ }
2282
+ this.handlePossibleCdpDisconnection(
2283
+ tab,
2284
+ cdpSession,
2285
+ new Error('The tab CDP session disconnected'),
2286
+ );
2287
+ };
2288
+ tab.cdpSession = cdpSession;
2289
+ tab.cdpConnection = cdpConnection;
2290
+ tab.screencastFrameListener = frameListener;
2291
+ tab.cdpSessionDetachedListener = cdpSessionDetachedListener;
2292
+ tab.generation = generation;
2293
+ tab.streaming = true;
2294
+ tab.streamInvalidated = false;
2295
+ cdpSession.on('Page.screencastFrame', frameListener);
2296
+ cdpConnection?.on(
2297
+ plugins.puppeteer.CDPSessionEvent.SessionDetached,
2298
+ cdpSessionDetachedListener,
2299
+ );
2300
+
2301
+ const format = this.options.screencast?.format ?? 'jpeg';
2302
+ try {
2303
+ await cdpSession.send('Page.startScreencast', {
2304
+ format,
2305
+ quality: this.options.screencast?.quality ?? 80,
2306
+ maxWidth: this.options.screencast?.maxWidth,
2307
+ maxHeight: this.options.screencast?.maxHeight,
2308
+ everyNthFrame: this.options.screencast?.everyNthFrame ?? 1,
2309
+ });
2310
+ this.emitState();
2311
+ } catch (error) {
2312
+ tab.streaming = false;
2313
+ tab.streamInvalidated = true;
2314
+ tab.cdpSession = undefined;
2315
+ tab.cdpConnection = undefined;
2316
+ tab.screencastFrameListener = undefined;
2317
+ tab.cdpSessionDetachedListener = undefined;
2318
+ cdpSession.off('Page.screencastFrame', frameListener);
2319
+ cdpConnection?.off(
2320
+ plugins.puppeteer.CDPSessionEvent.SessionDetached,
2321
+ cdpSessionDetachedListener,
2322
+ );
2323
+ if (!cdpSession.detached) {
2324
+ try {
2325
+ await cdpSession.detach();
2326
+ } catch {
2327
+ // The target may have closed while screencast startup was failing.
2328
+ }
2329
+ }
2330
+ throw error;
2331
+ }
2332
+ }
2333
+
2334
+ private async stopScreencast(tab: IPrivateLiveBrowserTab): Promise<void> {
2335
+ const cdpSession = tab.cdpSession;
2336
+ const cdpConnection = tab.cdpConnection;
2337
+ const frameListener = tab.screencastFrameListener;
2338
+ const cdpSessionDetachedListener = tab.cdpSessionDetachedListener;
2339
+ tab.streaming = false;
2340
+ tab.streamInvalidated = true;
2341
+ tab.cdpSession = undefined;
2342
+ tab.cdpConnection = undefined;
2343
+ tab.screencastFrameListener = undefined;
2344
+ tab.cdpSessionDetachedListener = undefined;
2345
+ await this.retireOutstandingFrames((frame) => frame.tabId === tab.id);
2346
+ if (!cdpSession) {
2347
+ return;
2348
+ }
2349
+
2350
+ if (!cdpSession.detached) {
2351
+ try {
2352
+ await cdpSession.send('Page.stopScreencast');
2353
+ } catch {
2354
+ // Page close and browser disconnect detach the target before cleanup runs.
2355
+ }
2356
+ }
2357
+ await this.retireOutstandingFrames((frame) => frame.tabId === tab.id);
2358
+ if (frameListener) {
2359
+ cdpSession.off('Page.screencastFrame', frameListener);
2360
+ }
2361
+ if (cdpConnection && cdpSessionDetachedListener) {
2362
+ cdpConnection.off(
2363
+ plugins.puppeteer.CDPSessionEvent.SessionDetached,
2364
+ cdpSessionDetachedListener,
2365
+ );
2366
+ }
2367
+ if (!cdpSession.detached) {
2368
+ try {
2369
+ await cdpSession.detach();
2370
+ } catch {
2371
+ // A concurrent page close can detach the session first.
2372
+ }
2373
+ }
2374
+ }
2375
+
2376
+ private handleScreencastFrame(
2377
+ tab: IPrivateLiveBrowserTab,
2378
+ cdpSession: plugins.puppeteer.CDPSession,
2379
+ generation: number,
2380
+ event: TScreencastFrameEvent,
2381
+ ): void {
2382
+ if (
2383
+ this.status !== 'running'
2384
+ || this.activeTabId !== tab.id
2385
+ || tab.status !== 'open'
2386
+ || tab.streamInvalidated
2387
+ || !tab.streaming
2388
+ || tab.generation !== generation
2389
+ || tab.appliedViewportRevision !== this.viewportRevision
2390
+ || tab.cdpSession !== cdpSession
2391
+ ) {
2392
+ this.acknowledgeCdpFrameInBackground({
2393
+ tabId: tab.id,
2394
+ generation,
2395
+ viewportRevision: this.viewportRevision,
2396
+ cdpSessionId: event.sessionId,
2397
+ cdpSession,
2398
+ });
2399
+ return;
2400
+ }
2401
+
2402
+ const format = this.options.screencast?.format ?? 'jpeg';
2403
+ const data = new Uint8Array(plugins.Buffer.from(event.data, 'base64'));
2404
+ const dimensions = readImageDimensions(data, format, {
2405
+ width: Math.max(1, Math.round(event.metadata.deviceWidth)),
2406
+ height: Math.max(1, Math.round(event.metadata.deviceHeight)),
2407
+ });
2408
+ const sequence = ++this.frameSequence;
2409
+ const outstandingFrame: IOutstandingFrame = {
2410
+ tabId: tab.id,
2411
+ generation,
2412
+ viewportRevision: this.viewportRevision,
2413
+ cdpSessionId: event.sessionId,
2414
+ cdpSession,
2415
+ };
2416
+ while (this.outstandingFrames.size >= maxOutstandingFrames) {
2417
+ const oldestFrameEntry = this.outstandingFrames.entries().next().value as
2418
+ | [number, IOutstandingFrame]
2419
+ | undefined;
2420
+ if (!oldestFrameEntry) {
2421
+ break;
2422
+ }
2423
+ this.outstandingFrames.delete(oldestFrameEntry[0]);
2424
+ this.acknowledgeCdpFrameInBackground(oldestFrameEntry[1]);
2425
+ }
2426
+ this.outstandingFrames.set(sequence, outstandingFrame);
2427
+ const frame: ILiveBrowserFrame = {
2428
+ tabId: tab.id,
2429
+ sequence,
2430
+ generation,
2431
+ viewportRevision: this.viewportRevision,
2432
+ viewport: { ...this.viewport },
2433
+ format,
2434
+ mimeType: format === 'jpeg' ? 'image/jpeg' : 'image/png',
2435
+ ...dimensions,
2436
+ metadata: {
2437
+ offsetTop: event.metadata.offsetTop,
2438
+ pageScaleFactor: event.metadata.pageScaleFactor,
2439
+ deviceWidth: event.metadata.deviceWidth,
2440
+ deviceHeight: event.metadata.deviceHeight,
2441
+ scrollOffsetX: event.metadata.scrollOffsetX,
2442
+ scrollOffsetY: event.metadata.scrollOffsetY,
2443
+ ...(event.metadata.timestamp !== undefined
2444
+ ? { timestamp: event.metadata.timestamp }
2445
+ : {}),
2446
+ },
2447
+ data,
2448
+ };
2449
+ this.emitEvent({ type: 'frame', frame });
2450
+ }
2451
+
2452
+ private async acknowledgeCdpFrame(frame: IOutstandingFrame): Promise<boolean> {
2453
+ if (frame.cdpSession.detached) {
2454
+ this.handleFrameAcknowledgementFailure(
2455
+ frame,
2456
+ new Error('CDP session detached before frame acknowledgement'),
2457
+ );
2458
+ return false;
2459
+ }
2460
+ try {
2461
+ await frame.cdpSession.send('Page.screencastFrameAck', {
2462
+ sessionId: frame.cdpSessionId,
2463
+ });
2464
+ return true;
2465
+ } catch (error) {
2466
+ this.handleFrameAcknowledgementFailure(frame, error);
2467
+ return false;
2468
+ }
2469
+ }
2470
+
2471
+ private acknowledgeCdpFrameInBackground(frame: IOutstandingFrame): void {
2472
+ void this.acknowledgeCdpFrame(frame);
2473
+ }
2474
+
2475
+ private handleFrameAcknowledgementFailure(
2476
+ frame: IOutstandingFrame,
2477
+ error: unknown,
2478
+ ): void {
2479
+ const tab = this.tabs.get(frame.tabId);
2480
+ const isCurrentStream = Boolean(
2481
+ tab
2482
+ && tab.cdpSession === frame.cdpSession
2483
+ && !tab.streamInvalidated,
2484
+ );
2485
+ if (
2486
+ isCurrentStream
2487
+ && this.status === 'running'
2488
+ && !this.normalStopRequested
2489
+ ) {
2490
+ this.emitError({
2491
+ code: 'frame_acknowledgement_failed',
2492
+ message: normalizeErrorMessage(error),
2493
+ fatal: false,
2494
+ tabId: frame.tabId,
2495
+ });
2496
+ }
2497
+ if (tab) {
2498
+ this.handlePossibleCdpDisconnection(tab, frame.cdpSession, error);
2499
+ }
2500
+ }
2501
+
2502
+ private async retireOutstandingFrames(
2503
+ predicate: (frame: IOutstandingFrame) => boolean,
2504
+ ): Promise<void> {
2505
+ const acknowledgements: Array<Promise<boolean>> = [];
2506
+ for (const [sequence, frame] of this.outstandingFrames) {
2507
+ if (!predicate(frame)) {
2508
+ continue;
2509
+ }
2510
+ this.outstandingFrames.delete(sequence);
2511
+ acknowledgements.push(this.acknowledgeCdpFrame(frame));
2512
+ }
2513
+ await Promise.all(acknowledgements);
2514
+ }
2515
+
2516
+ private handlePossibleCdpDisconnection(
2517
+ tab: IPrivateLiveBrowserTab,
2518
+ cdpSession: plugins.puppeteer.CDPSession,
2519
+ error: unknown,
2520
+ ): void {
2521
+ if (
2522
+ this.normalStopRequested
2523
+ || this.status !== 'running'
2524
+ || tab.cdpSession !== cdpSession
2525
+ || tab.streamInvalidated
2526
+ || !cdpSession.detached
2527
+ ) {
2528
+ return;
2529
+ }
2530
+ tab.streamInvalidated = true;
2531
+ this.retireFramesForTabInBackground(tab.id);
2532
+ this.scheduleOperation(async () => {
2533
+ if (!this.tabs.has(tab.id) || tab.cdpSession !== cdpSession) {
2534
+ return;
2535
+ }
2536
+ await this.stopScreencast(tab);
2537
+ tab.status = 'crashed';
2538
+ const wasActive = this.activeTabId === tab.id;
2539
+ if (wasActive) {
2540
+ this.activeTabId = null;
2541
+ }
2542
+ this.emitError({
2543
+ code: 'cdp_disconnected',
2544
+ message: normalizeErrorMessage(error),
2545
+ fatal: false,
2546
+ tabId: tab.id,
2547
+ });
2548
+ if (wasActive) {
2549
+ await this.activateReplacementTab();
2550
+ }
2551
+ }, 'cdp_disconnect_cleanup_failed', tab.id, true);
2552
+ }
2553
+
2554
+ private requireRawInputTarget(
2555
+ input: { tabId: string; generation: number; viewportRevision: number },
2556
+ ): { tab: IPrivateLiveBrowserTab; cdpSession: plugins.puppeteer.CDPSession } {
2557
+ const tab = this.requireInputTarget(input, true);
2558
+ const cdpSession = tab.cdpSession;
2559
+ if (!tab.streaming || tab.streamInvalidated || !cdpSession || cdpSession.detached) {
2560
+ throw new Error(`Tab input transport is not available: ${tab.id}`);
2561
+ }
2562
+ return { tab, cdpSession };
2563
+ }
2564
+
2565
+ private requireSemanticActionTarget(
2566
+ input: { tabId: string; generation: number; viewportRevision: number },
2567
+ ): IPrivateLiveBrowserTab {
2568
+ return this.requireInputTarget(input, false);
2569
+ }
2570
+
2571
+ private requireInputTarget(
2572
+ input: { tabId: string; generation: number; viewportRevision: number },
2573
+ requireActive: boolean,
2574
+ ): IPrivateLiveBrowserTab {
2575
+ if (this.status !== 'running') {
2576
+ throw new Error('LiveBrowserSession is not running');
2577
+ }
2578
+ const tab = this.requireTab(input.tabId);
2579
+ if (requireActive && this.activeTabId !== tab.id) {
2580
+ throw new Error(`Tab is not active: ${tab.id}`);
2581
+ }
2582
+ if (!Number.isInteger(input.generation) || input.generation !== tab.generation) {
2583
+ throw new Error(
2584
+ `Stale tab generation ${input.generation}; current generation is ${tab.generation}`,
2585
+ );
2586
+ }
2587
+ if (input.viewportRevision !== this.viewportRevision) {
2588
+ throw new Error(
2589
+ `Stale viewport revision ${input.viewportRevision}; current revision is ${this.viewportRevision}`,
2590
+ );
2591
+ }
2592
+ if (tab.status !== 'open' || tab.page.isClosed()) {
2593
+ throw new Error(`Tab is not available: ${tab.id}`);
2594
+ }
2595
+ return tab;
2596
+ }
2597
+
2598
+ private validateCoordinates(x: number, y: number): void {
2599
+ validateFiniteNumber(x, 'x', 0, this.viewport.width);
2600
+ validateFiniteNumber(y, 'y', 0, this.viewport.height);
2601
+ if (x >= this.viewport.width) {
2602
+ throw new Error(`x must be less than viewport width ${this.viewport.width}`);
2603
+ }
2604
+ if (y >= this.viewport.height) {
2605
+ throw new Error(`y must be less than viewport height ${this.viewport.height}`);
2606
+ }
2607
+ }
2608
+
2609
+ private createModifierMask(modifiers?: ILiveBrowserModifierState): number {
2610
+ if (modifiers === undefined) {
2611
+ return 0;
2612
+ }
2613
+ if (!modifiers || typeof modifiers !== 'object' || Array.isArray(modifiers)) {
2614
+ throw new Error('modifiers must be an object');
2615
+ }
2616
+ const allowedKeys = new Set(['alt', 'control', 'meta', 'shift']);
2617
+ for (const key of Object.keys(modifiers)) {
2618
+ if (!allowedKeys.has(key)) {
2619
+ throw new Error(`Unknown modifier: ${key}`);
2620
+ }
2621
+ }
2622
+ validateOptionalBoolean(modifiers.alt, 'modifiers.alt');
2623
+ validateOptionalBoolean(modifiers.control, 'modifiers.control');
2624
+ validateOptionalBoolean(modifiers.meta, 'modifiers.meta');
2625
+ validateOptionalBoolean(modifiers.shift, 'modifiers.shift');
2626
+ return (
2627
+ (modifiers.alt ? 1 : 0)
2628
+ | (modifiers.control ? 2 : 0)
2629
+ | (modifiers.meta ? 4 : 0)
2630
+ | (modifiers.shift ? 8 : 0)
2631
+ );
2632
+ }
2633
+
2634
+ private validateUrl(url: unknown): string {
2635
+ const validatedUrl = validateBoundedString(url, 'url', 1, maxUrlLength);
2636
+ let parsedUrl: URL;
2637
+ try {
2638
+ parsedUrl = new URL(validatedUrl);
2639
+ } catch {
2640
+ throw new Error('url must be absolute');
2641
+ }
2642
+ if (
2643
+ this.options.security?.httpNavigationOnly
2644
+ && parsedUrl.protocol !== 'http:'
2645
+ && parsedUrl.protocol !== 'https:'
2646
+ ) {
2647
+ throw new Error('url protocol must be http or https');
2648
+ }
2649
+ return validatedUrl;
2650
+ }
2651
+
2652
+ private normalizeEvaluationOptions(
2653
+ options: ILiveBrowserEvaluateOptions,
2654
+ ): INormalizedEvaluationOptions {
2655
+ return {
2656
+ timeoutMs: options.timeoutMs === undefined
2657
+ ? 5000
2658
+ : validateInteger(options.timeoutMs, 'timeoutMs', 1, 30000),
2659
+ maxOutputBytes: options.maxOutputBytes === undefined
2660
+ ? 262144
2661
+ : validateInteger(
2662
+ options.maxOutputBytes,
2663
+ 'maxOutputBytes',
2664
+ 1,
2665
+ maxEvaluationOutputBytes,
2666
+ ),
2667
+ maxDepth: options.maxDepth === undefined
2668
+ ? 16
2669
+ : validateInteger(options.maxDepth, 'maxDepth', 1, maxEvaluationDepth),
2670
+ maxNodes: options.maxNodes === undefined
2671
+ ? 10000
2672
+ : validateInteger(options.maxNodes, 'maxNodes', 1, maxEvaluationNodes),
2673
+ maxStringBytes: options.maxStringBytes === undefined
2674
+ ? 65536
2675
+ : validateInteger(
2676
+ options.maxStringBytes,
2677
+ 'maxStringBytes',
2678
+ 0,
2679
+ maxEvaluationStringBytes,
2680
+ ),
2681
+ maxArrayLength: options.maxArrayLength === undefined
2682
+ ? 1000
2683
+ : validateInteger(
2684
+ options.maxArrayLength,
2685
+ 'maxArrayLength',
2686
+ 0,
2687
+ maxEvaluationArrayLength,
2688
+ ),
2689
+ maxObjectKeys: options.maxObjectKeys === undefined
2690
+ ? 1000
2691
+ : validateInteger(
2692
+ options.maxObjectKeys,
2693
+ 'maxObjectKeys',
2694
+ 0,
2695
+ maxEvaluationObjectKeys,
2696
+ ),
2697
+ };
2698
+ }
2699
+
2700
+ private readCdpExceptionMessage(
2701
+ exceptionDetails: plugins.puppeteer.Protocol.Runtime.ExceptionDetails,
2702
+ ): string {
2703
+ const description = exceptionDetails.exception?.description;
2704
+ if (typeof description === 'string' && description.length > 0) {
2705
+ return truncate(description, 2048);
2706
+ }
2707
+ return truncate(exceptionDetails.text, 2048);
2708
+ }
2709
+
2710
+ private createEvaluationBootstrapExpression(): string {
2711
+ const bootstrapKeyLiteral = JSON.stringify(evaluationBootstrapKey);
2712
+ const cancelKeyLiteral = JSON.stringify(evaluationCancelKey);
2713
+ const cleanupKeyLiteral = JSON.stringify(evaluationCleanupKey);
2714
+ return `(() => {
2715
+ 'use strict';
2716
+ if (typeof globalThis[${bootstrapKeyLiteral}] === 'function') {
2717
+ return true;
2718
+ }
2719
+ const safeGlobalThis = globalThis;
2720
+ const SafeArray = Array;
2721
+ const SafeError = Error;
2722
+ const SafeFunction = Function;
2723
+ const SafeJSON = JSON;
2724
+ const SafeMap = Map;
2725
+ const SafeNumber = Number;
2726
+ const SafeObject = Object;
2727
+ const SafePromise = Promise;
2728
+ const SafeString = String;
2729
+ const SafeTextEncoder = TextEncoder;
2730
+ const SafeWeakSet = WeakSet;
2731
+ const SafeClearTimeout = clearTimeout;
2732
+ const SafeSetTimeout = setTimeout;
2733
+ const safeCreate = SafeObject.create;
2734
+ const safeDefineProperty = SafeObject.defineProperty;
2735
+ const safeGetOwnPropertyDescriptor = SafeObject.getOwnPropertyDescriptor;
2736
+ const safeGetOwnPropertySymbols = SafeObject.getOwnPropertySymbols;
2737
+ const safeGetPrototypeOf = SafeObject.getPrototypeOf;
2738
+ const safeIsArray = SafeArray.isArray;
2739
+ const safeIsFinite = SafeNumber.isFinite;
2740
+ const safeKeys = SafeObject.keys;
2741
+ const safeSetPrototypeOf = SafeObject.setPrototypeOf;
2742
+ const safeStringify = SafeJSON.stringify;
2743
+ const safeMapDelete = SafeFunction.prototype.call.bind(SafeMap.prototype.delete);
2744
+ const safeMapGet = SafeFunction.prototype.call.bind(SafeMap.prototype.get);
2745
+ const safeMapSet = SafeFunction.prototype.call.bind(SafeMap.prototype.set);
2746
+ const safePromiseThen = SafeFunction.prototype.call.bind(SafePromise.prototype.then);
2747
+ const safeWeakSetAdd = SafeFunction.prototype.call.bind(SafeWeakSet.prototype.add);
2748
+ const safeWeakSetHas = SafeFunction.prototype.call.bind(SafeWeakSet.prototype.has);
2749
+ const safeEncode = SafeFunction.prototype.call.bind(SafeTextEncoder.prototype.encode);
2750
+ const encoder = new SafeTextEncoder();
2751
+ const cancellationHandlers = new SafeMap();
2752
+
2753
+ const createEnvelope = (ok, value) => {
2754
+ const envelope = safeCreate(null);
2755
+ envelope.ok = ok;
2756
+ if (ok) {
2757
+ envelope.json = value;
2758
+ } else {
2759
+ envelope.error = value;
2760
+ }
2761
+ return envelope;
2762
+ };
2763
+ const byteLength = (value) => safeEncode(encoder, value).byteLength;
2764
+ const cancelEvaluation = (cancellationKey) => {
2765
+ const handler = safeMapGet(cancellationHandlers, cancellationKey);
2766
+ if (typeof handler === 'function') {
2767
+ handler();
2768
+ }
2769
+ return safeMapDelete(cancellationHandlers, cancellationKey);
2770
+ };
2771
+ const evaluate = async (source, options, cancellationKey) => {
2772
+ const seen = new SafeWeakSet();
2773
+ let visitedNodes = 0;
2774
+ const normalize = (value, depth) => {
2775
+ visitedNodes += 1;
2776
+ if (visitedNodes > options.maxNodes) {
2777
+ throw new SafeError('Evaluation result exceeded maxNodes');
2778
+ }
2779
+ if (depth > options.maxDepth) {
2780
+ throw new SafeError('Evaluation result exceeded maxDepth');
2781
+ }
2782
+ if (value === null || typeof value === 'boolean') {
2783
+ return value;
2784
+ }
2785
+ if (typeof value === 'number') {
2786
+ if (!safeIsFinite(value)) {
2787
+ throw new SafeError('Evaluation result contains a non-finite number');
2788
+ }
2789
+ return value;
2790
+ }
2791
+ if (typeof value === 'string') {
2792
+ if (byteLength(value) > options.maxStringBytes) {
2793
+ throw new SafeError('Evaluation result string exceeded maxStringBytes');
2794
+ }
2795
+ return value;
2796
+ }
2797
+ if (typeof value !== 'object') {
2798
+ throw new SafeError('Evaluation result contains a non-JSON value');
2799
+ }
2800
+ if (safeWeakSetHas(seen, value)) {
2801
+ throw new SafeError('Evaluation result contains a cycle or repeated object');
2802
+ }
2803
+ safeWeakSetAdd(seen, value);
2804
+
2805
+ if (safeIsArray(value)) {
2806
+ if (value.length > options.maxArrayLength) {
2807
+ throw new SafeError('Evaluation result array exceeded maxArrayLength');
2808
+ }
2809
+ const keys = safeKeys(value);
2810
+ if (keys.length !== value.length) {
2811
+ throw new SafeError('Evaluation result contains a sparse or extended array');
2812
+ }
2813
+ const output = SafeArray(value.length);
2814
+ safeSetPrototypeOf(output, null);
2815
+ for (let index = 0; index < value.length; index += 1) {
2816
+ const descriptor = safeGetOwnPropertyDescriptor(value, SafeString(index));
2817
+ if (!descriptor || !('value' in descriptor)) {
2818
+ throw new SafeError('Evaluation result contains an array accessor');
2819
+ }
2820
+ output[index] = normalize(descriptor.value, depth + 1);
2821
+ }
2822
+ return output;
2823
+ }
2824
+
2825
+ const prototype = safeGetPrototypeOf(value);
2826
+ if (prototype !== SafeObject.prototype && prototype !== null) {
2827
+ throw new SafeError('Evaluation result contains a non-plain object');
2828
+ }
2829
+ if (safeGetOwnPropertySymbols(value).length > 0) {
2830
+ throw new SafeError('Evaluation result contains symbol properties');
2831
+ }
2832
+ const keys = safeKeys(value);
2833
+ if (keys.length > options.maxObjectKeys) {
2834
+ throw new SafeError('Evaluation result object exceeded maxObjectKeys');
2835
+ }
2836
+ const output = safeCreate(null);
2837
+ for (let index = 0; index < keys.length; index += 1) {
2838
+ const key = keys[index];
2839
+ if (byteLength(key) > options.maxStringBytes) {
2840
+ throw new SafeError('Evaluation result key exceeded maxStringBytes');
2841
+ }
2842
+ const descriptor = safeGetOwnPropertyDescriptor(value, key);
2843
+ if (!descriptor || !('value' in descriptor)) {
2844
+ throw new SafeError('Evaluation result contains an object accessor');
2845
+ }
2846
+ output[key] = normalize(descriptor.value, depth + 1);
2847
+ }
2848
+ return output;
2849
+ };
2850
+
2851
+ try {
2852
+ const execute = SafeFunction(
2853
+ '\"use strict\"; return (async () => (' + source + '\\n))();',
2854
+ );
2855
+ const boundedExecution = new SafePromise((resolve, reject) => {
2856
+ let timeout;
2857
+ safeMapSet(cancellationHandlers, cancellationKey, () => {
2858
+ if (timeout !== undefined) {
2859
+ SafeClearTimeout(timeout);
2860
+ }
2861
+ reject(new SafeError('Evaluation cancelled'));
2862
+ });
2863
+ timeout = SafeSetTimeout(() => {
2864
+ reject(new SafeError('Evaluation timed out after ' + options.timeoutMs + 'ms'));
2865
+ }, options.timeoutMs);
2866
+ safePromiseThen(
2867
+ execute(),
2868
+ (value) => {
2869
+ SafeClearTimeout(timeout);
2870
+ resolve(value);
2871
+ },
2872
+ (error) => {
2873
+ SafeClearTimeout(timeout);
2874
+ reject(error);
2875
+ },
2876
+ );
2877
+ });
2878
+ let normalizedResult;
2879
+ try {
2880
+ normalizedResult = normalize(await boundedExecution, 0);
2881
+ } finally {
2882
+ safeMapDelete(cancellationHandlers, cancellationKey);
2883
+ }
2884
+ const json = safeStringify(normalizedResult);
2885
+ if (typeof json !== 'string' || byteLength(json) > options.maxOutputBytes) {
2886
+ throw new SafeError('Evaluation result exceeded maxOutputBytes');
2887
+ }
2888
+ return createEnvelope(true, json);
2889
+ } catch (error) {
2890
+ let message = 'Evaluation failed';
2891
+ if (typeof error === 'string') {
2892
+ message = error;
2893
+ } else if (error && typeof error === 'object') {
2894
+ const descriptor = safeGetOwnPropertyDescriptor(error, 'message');
2895
+ if (descriptor && 'value' in descriptor && typeof descriptor.value === 'string') {
2896
+ message = descriptor.value;
2897
+ }
2898
+ }
2899
+ if (byteLength(message) > 2048) {
2900
+ message = 'Evaluation failed with an oversized error';
2901
+ }
2902
+ return createEnvelope(false, message);
2903
+ }
2904
+ };
2905
+
2906
+ for (const [key, value] of [
2907
+ [${bootstrapKeyLiteral}, evaluate],
2908
+ [${cancelKeyLiteral}, cancelEvaluation],
2909
+ [${cleanupKeyLiteral}, cancelEvaluation],
2910
+ ]) {
2911
+ safeDefineProperty(safeGlobalThis, key, {
2912
+ value,
2913
+ configurable: false,
2914
+ enumerable: false,
2915
+ writable: false,
2916
+ });
2917
+ }
2918
+ return true;
2919
+ })()`;
2920
+ }
2921
+
2922
+ private createEvaluationExpression(
2923
+ expression: string,
2924
+ options: INormalizedEvaluationOptions,
2925
+ cancellationKey: string,
2926
+ ): string {
2927
+ return `globalThis[${JSON.stringify(evaluationBootstrapKey)}](${JSON.stringify(expression)}, ${JSON.stringify(options)}, ${JSON.stringify(cancellationKey)})`;
2928
+ }
2929
+
2930
+ private validateTimeout(timeoutMs?: number, defaultValue = 5000): number {
2931
+ if (timeoutMs === undefined) {
2932
+ return defaultValue;
2933
+ }
2934
+ return validateInteger(timeoutMs, 'timeoutMs', 1, maxTimeoutMs);
2935
+ }
2936
+
2937
+ private validateWaitUntil(waitUntil?: TLiveBrowserWaitUntil): TLiveBrowserWaitUntil {
2938
+ const validatedWaitUntil = waitUntil ?? 'load';
2939
+ if (!['load', 'domcontentloaded', 'networkidle0', 'networkidle2'].includes(validatedWaitUntil)) {
2940
+ throw new Error('waitUntil must be load, domcontentloaded, networkidle0, or networkidle2');
2941
+ }
2942
+ return validatedWaitUntil;
2943
+ }
2944
+
2945
+ private validateScreencastOptions(): void {
2946
+ const options = this.options.screencast;
2947
+ if (!options) {
2948
+ return;
2949
+ }
2950
+ if (options.format !== undefined && options.format !== 'jpeg' && options.format !== 'png') {
2951
+ throw new Error('screencast.format must be jpeg or png');
2952
+ }
2953
+ if (options.quality !== undefined) {
2954
+ validateInteger(options.quality, 'screencast.quality', 0, 100);
2955
+ }
2956
+ if (options.maxWidth !== undefined) {
2957
+ validateInteger(options.maxWidth, 'screencast.maxWidth', 1, maxViewportWidth);
2958
+ }
2959
+ if (options.maxHeight !== undefined) {
2960
+ validateInteger(options.maxHeight, 'screencast.maxHeight', 1, maxViewportHeight);
2961
+ }
2962
+ if (
2963
+ options.maxWidth !== undefined
2964
+ && options.maxHeight !== undefined
2965
+ && options.maxWidth * options.maxHeight > maxViewportPixelArea
2966
+ ) {
2967
+ throw new Error(
2968
+ `screencast pixel area must not exceed ${maxViewportPixelArea} pixels`,
2969
+ );
2970
+ }
2971
+ if (options.everyNthFrame !== undefined) {
2972
+ validateInteger(options.everyNthFrame, 'screencast.everyNthFrame', 1, 100);
2973
+ }
2974
+ }
2975
+ }