@push.rocks/smartpuppeteer 2.0.7 → 2.1.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,2298 @@
1
+ import { getEnvAwareBrowserInstance } from './smartpuppeteer.classes.smartpuppeteer.js';
2
+ import type {
3
+ ILiveBrowserClickOptions,
4
+ ILiveBrowserCreateTabOptions,
5
+ ILiveBrowserError,
6
+ ILiveBrowserFillOptions,
7
+ ILiveBrowserFrame,
8
+ ILiveBrowserFrameAcknowledgement,
9
+ ILiveBrowserFrameAcknowledgementRequest,
10
+ ILiveBrowserInsertTextInput,
11
+ ILiveBrowserKeyInput,
12
+ ILiveBrowserModifierState,
13
+ ILiveBrowserMouseInput,
14
+ ILiveBrowserNavigateOptions,
15
+ ILiveBrowserNavigationOptions,
16
+ ILiveBrowserObservation,
17
+ ILiveBrowserObserveOptions,
18
+ ILiveBrowserPressOptions,
19
+ ILiveBrowserSessionOptions,
20
+ ILiveBrowserSnapshot,
21
+ ILiveBrowserSnapshotOptions,
22
+ ILiveBrowserState,
23
+ ILiveBrowserTabState,
24
+ ILiveBrowserViewport,
25
+ ILiveBrowserWheelInput,
26
+ TLiveBrowserEvent,
27
+ TLiveBrowserEventListener,
28
+ TLiveBrowserImageFormat,
29
+ TLiveBrowserWaitUntil,
30
+ } from './smartpuppeteer.interfaces.livebrowser.js';
31
+ import * as plugins from './smartpuppeteer.plugins.js';
32
+
33
+ const defaultViewport: ILiveBrowserViewport = {
34
+ width: 800,
35
+ height: 600,
36
+ deviceScaleFactor: 1,
37
+ };
38
+
39
+ const maxViewportWidth = 4096;
40
+ const maxViewportHeight = 4096;
41
+ const maxDeviceScaleFactor = 3;
42
+ const maxViewportPixelArea = 8294400;
43
+ const maxSelectorLength = 4096;
44
+ const maxTextLength = 32768;
45
+ const maxUrlLength = 16384;
46
+ const maxTimeoutMs = 60000;
47
+ const maxOutstandingFrames = 3;
48
+ const maxQueuedPublicOperations = 64;
49
+ const maxQueuedInternalOperations = 128;
50
+
51
+ type TScreencastFrameEvent = plugins.puppeteer.Protocol.Page.ScreencastFrameEvent;
52
+ type TScreencastFrameListener = (event: TScreencastFrameEvent) => void;
53
+ type TCdpSessionDetachedListener = (session: plugins.puppeteer.CDPSession) => void;
54
+
55
+ interface IPrivateLiveBrowserTab {
56
+ id: string;
57
+ page: plugins.puppeteer.Page;
58
+ url: string;
59
+ title: string;
60
+ status: 'open' | 'crashed';
61
+ generation: number;
62
+ appliedViewportRevision: number;
63
+ streaming: boolean;
64
+ streamInvalidated: boolean;
65
+ navigationInProgress: boolean;
66
+ closing: boolean;
67
+ stateUpdateQueued: boolean;
68
+ stateUpdatePending: boolean;
69
+ navigationResetPending: boolean;
70
+ cdpSession?: plugins.puppeteer.CDPSession;
71
+ cdpConnection?: plugins.puppeteer.Connection;
72
+ screencastFrameListener?: TScreencastFrameListener;
73
+ cdpSessionDetachedListener?: TCdpSessionDetachedListener;
74
+ removeListeners: Array<() => void>;
75
+ }
76
+
77
+ interface IOutstandingFrame {
78
+ tabId: string;
79
+ generation: number;
80
+ viewportRevision: number;
81
+ cdpSessionId: number;
82
+ cdpSession: plugins.puppeteer.CDPSession;
83
+ }
84
+
85
+ interface IImageDimensions {
86
+ width: number;
87
+ height: number;
88
+ }
89
+
90
+ type TQueuedOperationKind = 'public' | 'internal' | 'shutdown';
91
+
92
+ interface IQueuedOperation {
93
+ kind: TQueuedOperationKind;
94
+ controller: AbortController;
95
+ run: (signal: AbortSignal) => Promise<unknown>;
96
+ resolve: (value: unknown) => void;
97
+ reject: (error: unknown) => void;
98
+ }
99
+
100
+ const validateBoundedString = (
101
+ value: unknown,
102
+ name: string,
103
+ minLength: number,
104
+ maxLength: number,
105
+ ): string => {
106
+ if (
107
+ typeof value !== 'string'
108
+ || value.length < minLength
109
+ || value.length > maxLength
110
+ ) {
111
+ throw new Error(`${name} must contain between ${minLength} and ${maxLength} characters`);
112
+ }
113
+ return value;
114
+ };
115
+
116
+ const validateFiniteNumber = (
117
+ value: unknown,
118
+ name: string,
119
+ minimum: number,
120
+ maximum: number,
121
+ ): number => {
122
+ if (
123
+ typeof value !== 'number'
124
+ || !Number.isFinite(value)
125
+ || value < minimum
126
+ || value > maximum
127
+ ) {
128
+ throw new Error(`${name} must be a finite number between ${minimum} and ${maximum}`);
129
+ }
130
+ return value;
131
+ };
132
+
133
+ const validateInteger = (
134
+ value: unknown,
135
+ name: string,
136
+ minimum: number,
137
+ maximum: number,
138
+ ): number => {
139
+ const validatedValue = validateFiniteNumber(value, name, minimum, maximum);
140
+ if (!Number.isInteger(validatedValue)) {
141
+ throw new Error(`${name} must be an integer`);
142
+ }
143
+ return validatedValue;
144
+ };
145
+
146
+ const validateOptionalBoolean = (value: unknown, name: string): void => {
147
+ if (value !== undefined && typeof value !== 'boolean') {
148
+ throw new Error(`${name} must be a boolean`);
149
+ }
150
+ };
151
+
152
+ const truncate = (value: string, maxLength: number): string => {
153
+ if (value.length <= maxLength) {
154
+ return value;
155
+ }
156
+ return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
157
+ };
158
+
159
+ const normalizeErrorMessage = (error: unknown): string => {
160
+ if (error instanceof Error) {
161
+ return truncate(error.message, 2048);
162
+ }
163
+ return truncate(String(error), 2048);
164
+ };
165
+
166
+ const readUint32 = (data: Uint8Array, offset: number): number => {
167
+ return (
168
+ data[offset]! * 0x1000000
169
+ + data[offset + 1]! * 0x10000
170
+ + data[offset + 2]! * 0x100
171
+ + data[offset + 3]!
172
+ );
173
+ };
174
+
175
+ const readImageDimensions = (
176
+ data: Uint8Array,
177
+ format: TLiveBrowserImageFormat,
178
+ fallback: IImageDimensions,
179
+ ): IImageDimensions => {
180
+ if (
181
+ format === 'png'
182
+ && data.length >= 24
183
+ && data[0] === 0x89
184
+ && data[1] === 0x50
185
+ && data[2] === 0x4e
186
+ && data[3] === 0x47
187
+ ) {
188
+ return {
189
+ width: readUint32(data, 16),
190
+ height: readUint32(data, 20),
191
+ };
192
+ }
193
+
194
+ if (format === 'jpeg' && data.length >= 4 && data[0] === 0xff && data[1] === 0xd8) {
195
+ let offset = 2;
196
+ while (offset + 8 < data.length) {
197
+ if (data[offset] !== 0xff) {
198
+ offset += 1;
199
+ continue;
200
+ }
201
+ const marker = data[offset + 1]!;
202
+ if (marker === 0xd8 || marker === 0xd9) {
203
+ offset += 2;
204
+ continue;
205
+ }
206
+ const segmentLength = (data[offset + 2]! << 8) + data[offset + 3]!;
207
+ if (segmentLength < 2 || offset + segmentLength + 2 > data.length) {
208
+ break;
209
+ }
210
+ if (
211
+ marker === 0xc0
212
+ || marker === 0xc1
213
+ || marker === 0xc2
214
+ || marker === 0xc3
215
+ || marker === 0xc5
216
+ || marker === 0xc6
217
+ || marker === 0xc7
218
+ || marker === 0xc9
219
+ || marker === 0xca
220
+ || marker === 0xcb
221
+ || marker === 0xcd
222
+ || marker === 0xce
223
+ || marker === 0xcf
224
+ ) {
225
+ return {
226
+ height: (data[offset + 5]! << 8) + data[offset + 6]!,
227
+ width: (data[offset + 7]! << 8) + data[offset + 8]!,
228
+ };
229
+ }
230
+ offset += segmentLength + 2;
231
+ }
232
+ }
233
+
234
+ return fallback;
235
+ };
236
+
237
+ const normalizeViewport = (viewport: ILiveBrowserViewport): ILiveBrowserViewport => {
238
+ const normalizedViewport = {
239
+ width: validateInteger(viewport.width, 'viewport.width', 1, maxViewportWidth),
240
+ height: validateInteger(viewport.height, 'viewport.height', 1, maxViewportHeight),
241
+ deviceScaleFactor: validateFiniteNumber(
242
+ viewport.deviceScaleFactor,
243
+ 'viewport.deviceScaleFactor',
244
+ 0.25,
245
+ maxDeviceScaleFactor,
246
+ ),
247
+ };
248
+ const physicalWidth = Math.ceil(
249
+ normalizedViewport.width * normalizedViewport.deviceScaleFactor,
250
+ );
251
+ const physicalHeight = Math.ceil(
252
+ normalizedViewport.height * normalizedViewport.deviceScaleFactor,
253
+ );
254
+ if (physicalWidth * physicalHeight > maxViewportPixelArea) {
255
+ throw new Error(
256
+ `viewport pixel area must not exceed ${maxViewportPixelArea} physical pixels`,
257
+ );
258
+ }
259
+ return normalizedViewport;
260
+ };
261
+
262
+ const createPuppeteerViewport = (
263
+ viewport: ILiveBrowserViewport,
264
+ ): plugins.puppeteer.Viewport => ({
265
+ ...viewport,
266
+ isMobile: false,
267
+ isLandscape: false,
268
+ hasTouch: false,
269
+ });
270
+
271
+ export class LiveBrowserSession {
272
+ private readonly options: ILiveBrowserSessionOptions;
273
+ private readonly eventListeners = new Set<TLiveBrowserEventListener>();
274
+ private readonly tabs = new Map<string, IPrivateLiveBrowserTab>();
275
+ private readonly tabIdsByPage = new WeakMap<plugins.puppeteer.Page, string>();
276
+ private readonly outstandingFrames = new Map<number, IOutstandingFrame>();
277
+
278
+ private browser?: plugins.puppeteer.Browser;
279
+ private browserContext?: plugins.puppeteer.BrowserContext;
280
+ private browserLifetimeController?: AbortController;
281
+ private browserDisconnectedListener?: () => void;
282
+ private readonly operationQueue: IQueuedOperation[] = [];
283
+ private activeOperation?: IQueuedOperation;
284
+ private operationRunning = false;
285
+ private admittedPublicOperations = 0;
286
+ private admittedInternalOperations = 0;
287
+ private shutdownPromise?: Promise<void>;
288
+ private status: 'stopped' | 'starting' | 'running' | 'stopping' = 'stopped';
289
+ private activeTabId: string | null = null;
290
+ private viewport: ILiveBrowserViewport = { ...defaultViewport };
291
+ private viewportRevision = 1;
292
+ private tabSequence = 0;
293
+ private frameSequence = 0;
294
+ private normalStopRequested = false;
295
+ private lastError?: ILiveBrowserError;
296
+
297
+ constructor(optionsArg: ILiveBrowserSessionOptions = {}) {
298
+ if (
299
+ optionsArg.launchOptions?.protocol
300
+ && optionsArg.launchOptions.protocol !== 'cdp'
301
+ ) {
302
+ throw new Error('LiveBrowserSession only supports Puppeteer CDP transport');
303
+ }
304
+ if (
305
+ optionsArg.launchOptions?.browser
306
+ && optionsArg.launchOptions.browser !== 'chrome'
307
+ ) {
308
+ throw new Error('LiveBrowserSession requires Chromium');
309
+ }
310
+ if (optionsArg.launchOptions && 'signal' in optionsArg.launchOptions) {
311
+ throw new Error('LiveBrowserSession owns launch cancellation; launchOptions.signal is unsupported');
312
+ }
313
+ const launchViewport = optionsArg.launchOptions?.defaultViewport;
314
+ const viewport = normalizeViewport(
315
+ optionsArg.viewport
316
+ ?? (launchViewport
317
+ ? {
318
+ width: launchViewport.width,
319
+ height: launchViewport.height,
320
+ deviceScaleFactor: launchViewport.deviceScaleFactor ?? 1,
321
+ }
322
+ : defaultViewport),
323
+ );
324
+ this.options = {
325
+ ...optionsArg,
326
+ launchOptions: {
327
+ ...optionsArg.launchOptions,
328
+ args: [...(optionsArg.launchOptions?.args ?? [])],
329
+ defaultViewport: createPuppeteerViewport(viewport),
330
+ },
331
+ viewport,
332
+ screencast: optionsArg.screencast ? { ...optionsArg.screencast } : undefined,
333
+ };
334
+ this.viewport = { ...viewport };
335
+ this.validateScreencastOptions();
336
+ }
337
+
338
+ public onEvent(listener: TLiveBrowserEventListener): () => void {
339
+ if (typeof listener !== 'function') {
340
+ throw new Error('listener must be a function');
341
+ }
342
+ this.eventListeners.add(listener);
343
+ return () => {
344
+ this.eventListeners.delete(listener);
345
+ };
346
+ }
347
+
348
+ public getState(): ILiveBrowserState {
349
+ return {
350
+ status: this.status,
351
+ activeTabId: this.activeTabId,
352
+ viewportRevision: this.viewportRevision,
353
+ viewport: { ...this.viewport },
354
+ tabs: [...this.tabs.values()].map((tab) => this.createTabState(tab)),
355
+ ...(this.lastError ? { lastError: { ...this.lastError } } : {}),
356
+ };
357
+ }
358
+
359
+ public async start(): Promise<void> {
360
+ return this.enqueuePublicOperation(async (signal) => {
361
+ if (this.status === 'running' || this.status === 'starting') {
362
+ return;
363
+ }
364
+
365
+ this.normalStopRequested = false;
366
+ this.lastError = undefined;
367
+ this.viewportRevision = 1;
368
+ const browserLifetimeController = new AbortController();
369
+ this.browserLifetimeController = browserLifetimeController;
370
+ this.status = 'starting';
371
+ this.emitState();
372
+
373
+ try {
374
+ if (signal.aborted) {
375
+ throw signal.reason;
376
+ }
377
+ if (browserLifetimeController.signal.aborted) {
378
+ throw browserLifetimeController.signal.reason;
379
+ }
380
+ this.browser = await getEnvAwareBrowserInstance({
381
+ forceNoSandbox: this.options.forceNoSandbox,
382
+ usePipe: this.options.usePipe,
383
+ launchOptions: {
384
+ ...this.options.launchOptions,
385
+ protocol: 'cdp',
386
+ signal: browserLifetimeController.signal,
387
+ },
388
+ });
389
+ if (signal.aborted) {
390
+ throw signal.reason;
391
+ }
392
+ this.browserContext = this.browser.defaultBrowserContext();
393
+ this.browserDisconnectedListener = () => {
394
+ if (this.normalStopRequested || this.status === 'stopped' || this.status === 'stopping') {
395
+ return;
396
+ }
397
+ const error: ILiveBrowserError = {
398
+ code: 'browser_disconnected',
399
+ message: 'The Chromium process disconnected',
400
+ fatal: true,
401
+ };
402
+ this.emitError(error);
403
+ void this.requestShutdown(error).catch((cleanupError) => {
404
+ this.emitError({
405
+ code: 'browser_disconnect_cleanup_failed',
406
+ message: normalizeErrorMessage(cleanupError),
407
+ fatal: true,
408
+ });
409
+ });
410
+ };
411
+ this.browser.on('disconnected', this.browserDisconnectedListener);
412
+
413
+ const initialPages = await this.browserContext.pages();
414
+ if (initialPages.length === 0) {
415
+ initialPages.push(await this.browserContext.newPage());
416
+ }
417
+ const initialPage = initialPages[0]!;
418
+ if (initialPage.url() !== 'about:blank') {
419
+ await initialPage.goto('about:blank');
420
+ }
421
+
422
+ for (const page of initialPages) {
423
+ await this.registerPage(page);
424
+ }
425
+
426
+ const firstTab = this.tabs.values().next().value as IPrivateLiveBrowserTab | undefined;
427
+ if (!firstTab) {
428
+ throw new Error('Chromium did not provide an initial page');
429
+ }
430
+ this.activeTabId = firstTab.id;
431
+ await firstTab.page.bringToFront();
432
+ this.status = 'running';
433
+ this.emitState();
434
+ await this.startScreencast(firstTab);
435
+ } catch (error) {
436
+ if (signal.aborted || this.normalStopRequested) {
437
+ await this.stopInternal();
438
+ throw error;
439
+ }
440
+ const startError: ILiveBrowserError = {
441
+ code: 'start_failed',
442
+ message: normalizeErrorMessage(error),
443
+ fatal: true,
444
+ };
445
+ this.emitError(startError);
446
+ await this.stopInternal(startError);
447
+ throw error;
448
+ }
449
+ });
450
+ }
451
+
452
+ public async stop(): Promise<void> {
453
+ this.normalStopRequested = true;
454
+ return this.requestShutdown();
455
+ }
456
+
457
+ public async acknowledgeFrame(
458
+ acknowledgement: ILiveBrowserFrameAcknowledgementRequest,
459
+ ): Promise<ILiveBrowserFrameAcknowledgement> {
460
+ if (!acknowledgement || typeof acknowledgement !== 'object') {
461
+ return { accepted: false };
462
+ }
463
+ const { tabId, sequence, generation, viewportRevision } = acknowledgement;
464
+ if (
465
+ typeof tabId !== 'string'
466
+ || tabId.length < 1
467
+ || tabId.length > 128
468
+ || !Number.isInteger(sequence)
469
+ || sequence < 1
470
+ || !Number.isInteger(generation)
471
+ || generation < 0
472
+ || !Number.isInteger(viewportRevision)
473
+ || viewportRevision < 1
474
+ ) {
475
+ return { accepted: false };
476
+ }
477
+ const outstandingFrame = this.outstandingFrames.get(sequence);
478
+ if (!outstandingFrame) {
479
+ return { accepted: false };
480
+ }
481
+ if (
482
+ outstandingFrame.tabId !== tabId
483
+ || outstandingFrame.generation !== generation
484
+ || outstandingFrame.viewportRevision !== viewportRevision
485
+ ) {
486
+ return { accepted: false };
487
+ }
488
+
489
+ const tab = this.tabs.get(outstandingFrame.tabId);
490
+ if (
491
+ !tab
492
+ || this.activeTabId !== tab.id
493
+ || tab.generation !== outstandingFrame.generation
494
+ || this.viewportRevision !== outstandingFrame.viewportRevision
495
+ || tab.cdpSession !== outstandingFrame.cdpSession
496
+ || outstandingFrame.cdpSession.detached
497
+ ) {
498
+ this.outstandingFrames.delete(sequence);
499
+ await this.acknowledgeCdpFrame(outstandingFrame);
500
+ return { accepted: false };
501
+ }
502
+
503
+ this.outstandingFrames.delete(sequence);
504
+ return {
505
+ accepted: await this.acknowledgeCdpFrame(outstandingFrame),
506
+ };
507
+ }
508
+
509
+ public async createTab(
510
+ optionsArg: ILiveBrowserCreateTabOptions = {},
511
+ ): Promise<ILiveBrowserTabState> {
512
+ const url = optionsArg.url === undefined ? undefined : this.validateUrl(optionsArg.url);
513
+ const activate = optionsArg.activate ?? true;
514
+ validateOptionalBoolean(optionsArg.activate, 'activate');
515
+ const timeout = this.validateTimeout(optionsArg.timeoutMs, 30000);
516
+ const waitUntil = this.validateWaitUntil(optionsArg.waitUntil);
517
+ return this.enqueuePublicOperation(async (signal) => {
518
+ const context = this.requireBrowserContext();
519
+ const previousActiveTabId = this.activeTabId;
520
+ let page: plugins.puppeteer.Page | undefined;
521
+ let tab: IPrivateLiveBrowserTab | undefined;
522
+ try {
523
+ page = await context.newPage();
524
+ if (signal.aborted) {
525
+ throw signal.reason;
526
+ }
527
+ tab = await this.registerPage(page);
528
+ if (url !== undefined) {
529
+ await this.navigateTab(
530
+ tab,
531
+ async () => {
532
+ await tab!.page.goto(url, { timeout, waitUntil, signal });
533
+ },
534
+ signal,
535
+ );
536
+ }
537
+
538
+ if (activate) {
539
+ await this.activateTabInternal(tab.id);
540
+ } else {
541
+ const previousActiveTab = previousActiveTabId
542
+ ? this.tabs.get(previousActiveTabId)
543
+ : undefined;
544
+ if (previousActiveTab && !previousActiveTab.page.isClosed()) {
545
+ await previousActiveTab.page.bringToFront();
546
+ }
547
+ this.emitState();
548
+ }
549
+ return this.createTabState(tab);
550
+ } catch (error) {
551
+ if (page) {
552
+ const rollbackError = await this.rollbackCreatedPage(
553
+ page,
554
+ tab,
555
+ previousActiveTabId,
556
+ );
557
+ if (rollbackError) {
558
+ throw new AggregateError(
559
+ [error, rollbackError],
560
+ 'Tab creation failed and its page could not be closed cleanly',
561
+ );
562
+ }
563
+ }
564
+ throw error;
565
+ }
566
+ });
567
+ }
568
+
569
+ public async activateTab(tabId: string): Promise<void> {
570
+ return this.enqueuePublicOperation(async () => {
571
+ await this.activateTabInternal(tabId);
572
+ });
573
+ }
574
+
575
+ public async closeTab(tabId: string): Promise<void> {
576
+ return this.enqueuePublicOperation(async () => {
577
+ const tab = this.requireTab(tabId);
578
+ const wasActive = this.activeTabId === tab.id;
579
+ if (wasActive) {
580
+ await this.stopScreencast(tab);
581
+ } else {
582
+ await this.retireOutstandingFrames((frame) => frame.tabId === tab.id);
583
+ }
584
+ try {
585
+ if (!tab.page.isClosed()) {
586
+ await tab.page.close();
587
+ }
588
+ } catch (error) {
589
+ if (
590
+ wasActive
591
+ && this.status === 'running'
592
+ && this.activeTabId === tab.id
593
+ && tab.status === 'open'
594
+ ) {
595
+ await this.startScreencast(tab);
596
+ }
597
+ this.emitState();
598
+ throw error;
599
+ }
600
+
601
+ tab.closing = true;
602
+ this.removePageListeners(tab);
603
+ this.tabs.delete(tab.id);
604
+ this.tabIdsByPage.delete(tab.page);
605
+
606
+ if (wasActive) {
607
+ this.activeTabId = null;
608
+ await this.activateReplacementTab();
609
+ } else {
610
+ this.emitState();
611
+ }
612
+ });
613
+ }
614
+
615
+ public async navigate(optionsArg: ILiveBrowserNavigateOptions): Promise<void> {
616
+ const url = this.validateUrl(optionsArg.url);
617
+ return this.enqueuePublicOperation(async (signal) => {
618
+ const tab = this.resolveNavigationTab(optionsArg.tabId);
619
+ await this.navigateTab(
620
+ tab,
621
+ async () => {
622
+ await tab.page.goto(url, this.createPuppeteerNavigationOptions(optionsArg, signal));
623
+ },
624
+ signal,
625
+ );
626
+ });
627
+ }
628
+
629
+ public async back(optionsArg: ILiveBrowserNavigationOptions = {}): Promise<void> {
630
+ return this.enqueuePublicOperation(async (signal) => {
631
+ const tab = this.resolveNavigationTab(optionsArg.tabId);
632
+ await this.navigateTab(
633
+ tab,
634
+ async () => {
635
+ await tab.page.goBack(this.createPuppeteerNavigationOptions(optionsArg, signal));
636
+ },
637
+ signal,
638
+ );
639
+ });
640
+ }
641
+
642
+ public async forward(optionsArg: ILiveBrowserNavigationOptions = {}): Promise<void> {
643
+ return this.enqueuePublicOperation(async (signal) => {
644
+ const tab = this.resolveNavigationTab(optionsArg.tabId);
645
+ await this.navigateTab(
646
+ tab,
647
+ async () => {
648
+ await tab.page.goForward(this.createPuppeteerNavigationOptions(optionsArg, signal));
649
+ },
650
+ signal,
651
+ );
652
+ });
653
+ }
654
+
655
+ public async reload(optionsArg: ILiveBrowserNavigationOptions = {}): Promise<void> {
656
+ return this.enqueuePublicOperation(async (signal) => {
657
+ const tab = this.resolveNavigationTab(optionsArg.tabId);
658
+ await this.navigateTab(
659
+ tab,
660
+ async () => {
661
+ await tab.page.reload(this.createPuppeteerNavigationOptions(optionsArg, signal));
662
+ },
663
+ signal,
664
+ );
665
+ });
666
+ }
667
+
668
+ public async setViewport(viewportArg: ILiveBrowserViewport): Promise<void> {
669
+ const viewport = normalizeViewport(viewportArg);
670
+ return this.enqueuePublicOperation(async (signal) => {
671
+ const tab = this.requireActiveTab();
672
+ await this.stopScreencast(tab);
673
+ try {
674
+ if (signal.aborted) {
675
+ throw signal.reason;
676
+ }
677
+ await tab.page.setViewport(createPuppeteerViewport(viewport));
678
+ this.viewport = { ...viewport };
679
+ this.viewportRevision += 1;
680
+ tab.appliedViewportRevision = this.viewportRevision;
681
+ this.emitState();
682
+ } finally {
683
+ if (this.status === 'running' && this.activeTabId === tab.id && tab.status === 'open') {
684
+ await this.startScreencast(tab);
685
+ }
686
+ }
687
+ });
688
+ }
689
+
690
+ public async dispatchMouse(input: ILiveBrowserMouseInput): Promise<void> {
691
+ const { tab, cdpSession } = this.requireRawInputTarget(input);
692
+ this.validateCoordinates(input.x, input.y);
693
+ const type = input.type === 'move'
694
+ ? 'mouseMoved'
695
+ : input.type === 'down'
696
+ ? 'mousePressed'
697
+ : input.type === 'up'
698
+ ? 'mouseReleased'
699
+ : undefined;
700
+ if (!type) {
701
+ throw new Error('mouse input type must be move, down, or up');
702
+ }
703
+ const allowedButtons = ['none', 'left', 'middle', 'right', 'back', 'forward'];
704
+ const button = input.button ?? (type === 'mouseMoved' ? 'none' : 'left');
705
+ if (!allowedButtons.includes(button)) {
706
+ throw new Error('mouse button is invalid');
707
+ }
708
+ const buttons = input.buttons === undefined
709
+ ? undefined
710
+ : validateInteger(input.buttons, 'buttons', 0, 31);
711
+ const clickCount = input.clickCount === undefined
712
+ ? undefined
713
+ : validateInteger(input.clickCount, 'clickCount', 0, 3);
714
+
715
+ try {
716
+ await cdpSession.send('Input.dispatchMouseEvent', {
717
+ type,
718
+ x: input.x,
719
+ y: input.y,
720
+ button,
721
+ buttons,
722
+ clickCount,
723
+ modifiers: this.createModifierMask(input.modifiers),
724
+ pointerType: 'mouse',
725
+ });
726
+ } catch (error) {
727
+ this.handlePossibleCdpDisconnection(tab, cdpSession, error);
728
+ throw error;
729
+ }
730
+ }
731
+
732
+ public async dispatchWheel(input: ILiveBrowserWheelInput): Promise<void> {
733
+ const { tab, cdpSession } = this.requireRawInputTarget(input);
734
+ this.validateCoordinates(input.x, input.y);
735
+ validateFiniteNumber(input.deltaX, 'deltaX', -1000000, 1000000);
736
+ validateFiniteNumber(input.deltaY, 'deltaY', -1000000, 1000000);
737
+ try {
738
+ await cdpSession.send('Input.dispatchMouseEvent', {
739
+ type: 'mouseWheel',
740
+ x: input.x,
741
+ y: input.y,
742
+ deltaX: input.deltaX,
743
+ deltaY: input.deltaY,
744
+ modifiers: this.createModifierMask(input.modifiers),
745
+ pointerType: 'mouse',
746
+ });
747
+ } catch (error) {
748
+ this.handlePossibleCdpDisconnection(tab, cdpSession, error);
749
+ throw error;
750
+ }
751
+ }
752
+
753
+ public async dispatchKey(input: ILiveBrowserKeyInput): Promise<void> {
754
+ const { tab, cdpSession } = this.requireRawInputTarget(input);
755
+ const type = input.type === 'down'
756
+ ? 'keyDown'
757
+ : input.type === 'up'
758
+ ? 'keyUp'
759
+ : undefined;
760
+ if (!type) {
761
+ throw new Error('key input type must be down or up');
762
+ }
763
+ const key = validateBoundedString(input.key, 'key', 1, 64);
764
+ const code = input.code === undefined
765
+ ? undefined
766
+ : validateBoundedString(input.code, 'code', 1, 64);
767
+ const text = input.text === undefined
768
+ ? undefined
769
+ : validateBoundedString(input.text, 'text', 0, 1024);
770
+ const unmodifiedText = input.unmodifiedText === undefined
771
+ ? undefined
772
+ : validateBoundedString(input.unmodifiedText, 'unmodifiedText', 0, 1024);
773
+ const windowsVirtualKeyCode = input.windowsVirtualKeyCode === undefined
774
+ ? undefined
775
+ : validateInteger(input.windowsVirtualKeyCode, 'windowsVirtualKeyCode', 0, 65535);
776
+ const nativeVirtualKeyCode = input.nativeVirtualKeyCode === undefined
777
+ ? undefined
778
+ : validateInteger(input.nativeVirtualKeyCode, 'nativeVirtualKeyCode', 0, 65535);
779
+ const location = input.location === undefined
780
+ ? undefined
781
+ : validateInteger(input.location, 'location', 0, 3);
782
+ validateOptionalBoolean(input.autoRepeat, 'autoRepeat');
783
+ validateOptionalBoolean(input.isKeypad, 'isKeypad');
784
+
785
+ try {
786
+ await cdpSession.send('Input.dispatchKeyEvent', {
787
+ type,
788
+ key,
789
+ code,
790
+ text,
791
+ unmodifiedText,
792
+ windowsVirtualKeyCode,
793
+ nativeVirtualKeyCode,
794
+ autoRepeat: input.autoRepeat,
795
+ isKeypad: input.isKeypad,
796
+ location,
797
+ modifiers: this.createModifierMask(input.modifiers),
798
+ });
799
+ } catch (error) {
800
+ this.handlePossibleCdpDisconnection(tab, cdpSession, error);
801
+ throw error;
802
+ }
803
+ }
804
+
805
+ public async insertText(input: ILiveBrowserInsertTextInput): Promise<void> {
806
+ const { tab, cdpSession } = this.requireRawInputTarget(input);
807
+ const text = validateBoundedString(input.text, 'text', 0, maxTextLength);
808
+ try {
809
+ await cdpSession.send('Input.insertText', { text });
810
+ } catch (error) {
811
+ this.handlePossibleCdpDisconnection(tab, cdpSession, error);
812
+ throw error;
813
+ }
814
+ }
815
+
816
+ public async captureSnapshot(
817
+ optionsArg: ILiveBrowserSnapshotOptions = {},
818
+ ): Promise<ILiveBrowserSnapshot> {
819
+ const format = optionsArg.format ?? 'jpeg';
820
+ if (format !== 'jpeg' && format !== 'png') {
821
+ throw new Error('snapshot format must be jpeg or png');
822
+ }
823
+ const quality = optionsArg.quality === undefined
824
+ ? undefined
825
+ : validateInteger(optionsArg.quality, 'quality', 0, 100);
826
+ if (format === 'png' && quality !== undefined) {
827
+ throw new Error('quality is only supported for jpeg snapshots');
828
+ }
829
+ if ('fullPage' in optionsArg) {
830
+ throw new Error('fullPage snapshots are not supported by LiveBrowserSession');
831
+ }
832
+ return this.enqueuePublicOperation(async (signal) => {
833
+ const tab = this.resolveActionTab(optionsArg.tabId);
834
+ await this.ensureTabViewport(tab);
835
+ const capturedTab = this.resolveActionTab(tab.id);
836
+ if (signal.aborted) {
837
+ throw signal.reason;
838
+ }
839
+ const viewport = { ...this.viewport };
840
+ const viewportRevision = this.viewportRevision;
841
+ const data = await capturedTab.page.screenshot({
842
+ type: format,
843
+ ...(format === 'jpeg' && quality !== undefined ? { quality } : {}),
844
+ });
845
+ if (signal.aborted) {
846
+ throw signal.reason;
847
+ }
848
+ const dimensions = readImageDimensions(data, format, {
849
+ width: Math.round(viewport.width * viewport.deviceScaleFactor),
850
+ height: Math.round(viewport.height * viewport.deviceScaleFactor),
851
+ });
852
+ return {
853
+ tabId: capturedTab.id,
854
+ viewportRevision,
855
+ viewport,
856
+ format,
857
+ mimeType: format === 'jpeg' ? 'image/jpeg' : 'image/png',
858
+ ...dimensions,
859
+ data,
860
+ };
861
+ });
862
+ }
863
+
864
+ public async observe(optionsArg: ILiveBrowserObserveOptions = {}): Promise<ILiveBrowserObservation> {
865
+ const maxCharacters = optionsArg.maxCharacters === undefined
866
+ ? 12000
867
+ : validateInteger(optionsArg.maxCharacters, 'maxCharacters', 256, 50000);
868
+ return this.enqueuePublicOperation(async (signal) => {
869
+ const tab = this.resolveActionTab(optionsArg.tabId);
870
+ await this.ensureTabViewport(tab);
871
+ const observedTab = this.resolveActionTab(tab.id);
872
+ if (signal.aborted) {
873
+ throw signal.reason;
874
+ }
875
+ await this.refreshTab(observedTab);
876
+ const accessibilitySnapshot = await observedTab.page.accessibility.snapshot({
877
+ interestingOnly: true,
878
+ });
879
+ if (signal.aborted) {
880
+ throw signal.reason;
881
+ }
882
+ const header = [
883
+ `Tab: ${observedTab.id}`,
884
+ `Status: ${observedTab.status}`,
885
+ `Title: ${observedTab.title}`,
886
+ `URL: ${observedTab.url}`,
887
+ 'Accessibility:',
888
+ ];
889
+ const lines = [...header];
890
+ let reachedTraversalLimit = false;
891
+
892
+ const appendNode = (
893
+ node: plugins.puppeteer.SerializedAXNode,
894
+ depth: number,
895
+ ): void => {
896
+ if (lines.join('\n').length >= maxCharacters) {
897
+ reachedTraversalLimit = true;
898
+ return;
899
+ }
900
+ const details: string[] = [node.role];
901
+ if (node.name) {
902
+ details.push(`"${truncate(node.name, 512)}"`);
903
+ }
904
+ if (node.value !== undefined) {
905
+ details.push(`value="${truncate(String(node.value), 512)}"`);
906
+ }
907
+ for (const property of [
908
+ 'disabled',
909
+ 'expanded',
910
+ 'focused',
911
+ 'readonly',
912
+ 'required',
913
+ 'selected',
914
+ 'checked',
915
+ 'pressed',
916
+ ] as const) {
917
+ const value = node[property];
918
+ if (value !== undefined && value !== false) {
919
+ details.push(`${property}=${String(value)}`);
920
+ }
921
+ }
922
+ lines.push(`${' '.repeat(Math.min(depth, 20))}- ${details.join(' ')}`);
923
+ for (const child of node.children ?? []) {
924
+ appendNode(child, depth + 1);
925
+ if (reachedTraversalLimit) {
926
+ break;
927
+ }
928
+ }
929
+ };
930
+
931
+ if (accessibilitySnapshot) {
932
+ appendNode(accessibilitySnapshot, 0);
933
+ } else {
934
+ lines.push('- No accessibility nodes');
935
+ }
936
+
937
+ const unboundedText = lines.join('\n');
938
+ const truncatedText = unboundedText.length > maxCharacters
939
+ ? unboundedText.slice(0, maxCharacters)
940
+ : unboundedText;
941
+ const state = this.getState();
942
+ return {
943
+ tabId: observedTab.id,
944
+ url: observedTab.url,
945
+ title: observedTab.title,
946
+ tab: this.createTabState(observedTab),
947
+ state,
948
+ text: truncatedText,
949
+ truncated: reachedTraversalLimit || unboundedText.length > maxCharacters,
950
+ };
951
+ });
952
+ }
953
+
954
+ public async click(optionsArg: ILiveBrowserClickOptions): Promise<void> {
955
+ const selector = validateBoundedString(
956
+ optionsArg.selector,
957
+ 'selector',
958
+ 1,
959
+ maxSelectorLength,
960
+ );
961
+ const timeout = this.validateTimeout(optionsArg.timeoutMs);
962
+ const allowedButtons = ['left', 'middle', 'right'];
963
+ if (optionsArg.button !== undefined && !allowedButtons.includes(optionsArg.button)) {
964
+ throw new Error('click button is invalid');
965
+ }
966
+ const clickCount = optionsArg.clickCount === undefined
967
+ ? undefined
968
+ : validateInteger(optionsArg.clickCount, 'clickCount', 1, 3);
969
+ return this.enqueuePublicOperation(async (signal) => {
970
+ const tab = this.requireSemanticActionTarget(optionsArg);
971
+ await this.ensureTabViewport(tab);
972
+ const actionTab = this.requireSemanticActionTarget(optionsArg);
973
+ await actionTab.page.locator(selector).setTimeout(timeout).click({
974
+ button: optionsArg.button,
975
+ count: clickCount,
976
+ signal,
977
+ });
978
+ await this.refreshTab(actionTab);
979
+ this.emitState();
980
+ });
981
+ }
982
+
983
+ public async fill(optionsArg: ILiveBrowserFillOptions): Promise<void> {
984
+ const selector = validateBoundedString(
985
+ optionsArg.selector,
986
+ 'selector',
987
+ 1,
988
+ maxSelectorLength,
989
+ );
990
+ const text = validateBoundedString(optionsArg.text, 'text', 0, maxTextLength);
991
+ const timeout = this.validateTimeout(optionsArg.timeoutMs);
992
+ return this.enqueuePublicOperation(async (signal) => {
993
+ const tab = this.requireSemanticActionTarget(optionsArg);
994
+ await this.ensureTabViewport(tab);
995
+ const actionTab = this.requireSemanticActionTarget(optionsArg);
996
+ await actionTab.page.locator(selector).setTimeout(timeout).fill(text, { signal });
997
+ await this.refreshTab(actionTab);
998
+ this.emitState();
999
+ });
1000
+ }
1001
+
1002
+ public async press(optionsArg: ILiveBrowserPressOptions): Promise<void> {
1003
+ const selector = validateBoundedString(
1004
+ optionsArg.selector,
1005
+ 'selector',
1006
+ 1,
1007
+ maxSelectorLength,
1008
+ );
1009
+ const key = validateBoundedString(optionsArg.key, 'key', 1, 64);
1010
+ const timeout = this.validateTimeout(optionsArg.timeoutMs);
1011
+ return this.enqueuePublicOperation(async (signal) => {
1012
+ const tab = this.requireSemanticActionTarget(optionsArg);
1013
+ await this.ensureTabViewport(tab);
1014
+ const actionTab = this.requireSemanticActionTarget(optionsArg);
1015
+ const element = await actionTab.page.waitForSelector(selector, {
1016
+ visible: true,
1017
+ timeout,
1018
+ signal,
1019
+ });
1020
+ if (!element) {
1021
+ throw new Error(`No visible element matched selector: ${selector}`);
1022
+ }
1023
+ try {
1024
+ await element.press(key as plugins.puppeteer.KeyInput);
1025
+ } finally {
1026
+ await element.dispose();
1027
+ }
1028
+ await this.refreshTab(actionTab);
1029
+ this.emitState();
1030
+ });
1031
+ }
1032
+
1033
+ private enqueuePublicOperation<T>(
1034
+ operation: (signal: AbortSignal) => Promise<T>,
1035
+ ): Promise<T> {
1036
+ if (
1037
+ this.status === 'stopping'
1038
+ || this.normalStopRequested
1039
+ || (this.status === 'stopped' && this.operationRunning)
1040
+ ) {
1041
+ return Promise.reject(new Error('LiveBrowserSession is stopping'));
1042
+ }
1043
+ if (this.admittedPublicOperations >= maxQueuedPublicOperations) {
1044
+ return Promise.reject(new Error('LiveBrowserSession operation queue is full'));
1045
+ }
1046
+ this.admittedPublicOperations += 1;
1047
+ return this.enqueueQueuedOperation('public', operation);
1048
+ }
1049
+
1050
+ private enqueueInternalOperation(
1051
+ operation: (signal: AbortSignal) => Promise<void>,
1052
+ priority = false,
1053
+ ): Promise<void> {
1054
+ if (this.status === 'stopped' || this.status === 'stopping') {
1055
+ return Promise.reject(new Error('LiveBrowserSession is stopping'));
1056
+ }
1057
+ this.admittedInternalOperations += 1;
1058
+ return this.enqueueQueuedOperation('internal', operation, priority);
1059
+ }
1060
+
1061
+ private enqueueQueuedOperation<T>(
1062
+ kind: TQueuedOperationKind,
1063
+ operation: (signal: AbortSignal) => Promise<T>,
1064
+ priority = false,
1065
+ ): Promise<T> {
1066
+ return new Promise<T>((resolve, reject) => {
1067
+ const queuedOperation: IQueuedOperation = {
1068
+ kind,
1069
+ controller: new AbortController(),
1070
+ run: operation,
1071
+ resolve: (value) => resolve(value as T),
1072
+ reject,
1073
+ };
1074
+ if (priority) {
1075
+ this.operationQueue.unshift(queuedOperation);
1076
+ } else {
1077
+ this.operationQueue.push(queuedOperation);
1078
+ }
1079
+ this.drainOperationQueue();
1080
+ });
1081
+ }
1082
+
1083
+ private drainOperationQueue(): void {
1084
+ if (this.operationRunning) {
1085
+ return;
1086
+ }
1087
+ const queuedOperation = this.operationQueue.shift();
1088
+ if (!queuedOperation) {
1089
+ return;
1090
+ }
1091
+ this.operationRunning = true;
1092
+ this.activeOperation = queuedOperation;
1093
+ void this.executeQueuedOperation(queuedOperation).catch((error) => {
1094
+ this.operationRunning = false;
1095
+ this.activeOperation = undefined;
1096
+ this.emitError({
1097
+ code: 'operation_scheduler_failed',
1098
+ message: normalizeErrorMessage(error),
1099
+ fatal: true,
1100
+ });
1101
+ this.drainOperationQueue();
1102
+ });
1103
+ }
1104
+
1105
+ private async executeQueuedOperation(queuedOperation: IQueuedOperation): Promise<void> {
1106
+ try {
1107
+ if (queuedOperation.controller.signal.aborted) {
1108
+ throw queuedOperation.controller.signal.reason;
1109
+ }
1110
+ queuedOperation.resolve(await queuedOperation.run(queuedOperation.controller.signal));
1111
+ } catch (error) {
1112
+ queuedOperation.reject(error);
1113
+ } finally {
1114
+ if (queuedOperation.kind === 'public') {
1115
+ this.admittedPublicOperations -= 1;
1116
+ } else if (queuedOperation.kind === 'internal') {
1117
+ this.admittedInternalOperations -= 1;
1118
+ }
1119
+ if (this.activeOperation === queuedOperation) {
1120
+ this.activeOperation = undefined;
1121
+ }
1122
+ this.operationRunning = false;
1123
+ if (
1124
+ this.status === 'stopped'
1125
+ && !this.operationQueue.some((operation) => operation.kind === 'shutdown')
1126
+ ) {
1127
+ this.normalStopRequested = false;
1128
+ }
1129
+ this.drainOperationQueue();
1130
+ }
1131
+ }
1132
+
1133
+ private cancelQueuedOperations(error: Error): void {
1134
+ for (const queuedOperation of this.operationQueue.splice(0)) {
1135
+ queuedOperation.controller.abort(error);
1136
+ if (queuedOperation.kind === 'public') {
1137
+ this.admittedPublicOperations -= 1;
1138
+ } else if (queuedOperation.kind === 'internal') {
1139
+ this.admittedInternalOperations -= 1;
1140
+ }
1141
+ queuedOperation.reject(error);
1142
+ }
1143
+ }
1144
+
1145
+ private beginShutdown(abortActiveOperation: boolean): void {
1146
+ if (this.status !== 'stopped' && this.status !== 'stopping') {
1147
+ this.status = 'stopping';
1148
+ this.emitState();
1149
+ }
1150
+ const shutdownError = new Error('LiveBrowserSession is stopping');
1151
+ if (
1152
+ abortActiveOperation
1153
+ && this.activeOperation
1154
+ && this.activeOperation.kind !== 'shutdown'
1155
+ ) {
1156
+ this.activeOperation.controller.abort(shutdownError);
1157
+ }
1158
+ if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
1159
+ this.browserLifetimeController.abort(shutdownError);
1160
+ }
1161
+ this.cancelQueuedOperations(shutdownError);
1162
+ }
1163
+
1164
+ private requestShutdown(error?: ILiveBrowserError): Promise<void> {
1165
+ if (this.status === 'stopped') {
1166
+ return Promise.resolve();
1167
+ }
1168
+ if (this.shutdownPromise) {
1169
+ return this.shutdownPromise;
1170
+ }
1171
+ this.beginShutdown(true);
1172
+ const shutdownPromise = this.enqueueQueuedOperation(
1173
+ 'shutdown',
1174
+ async () => this.stopInternal(error),
1175
+ true,
1176
+ );
1177
+ this.shutdownPromise = shutdownPromise;
1178
+ void shutdownPromise.then(
1179
+ () => {
1180
+ if (this.shutdownPromise === shutdownPromise) {
1181
+ this.shutdownPromise = undefined;
1182
+ }
1183
+ },
1184
+ () => {
1185
+ if (this.shutdownPromise === shutdownPromise) {
1186
+ this.shutdownPromise = undefined;
1187
+ }
1188
+ },
1189
+ );
1190
+ return shutdownPromise;
1191
+ }
1192
+
1193
+ private scheduleOperation(
1194
+ operation: (signal: AbortSignal) => Promise<void>,
1195
+ errorCode: string,
1196
+ tabId?: string,
1197
+ priority = false,
1198
+ ): boolean {
1199
+ if (this.admittedInternalOperations >= maxQueuedInternalOperations) {
1200
+ const queueError = new Error('LiveBrowserSession internal operation queue is full');
1201
+ const liveBrowserError: ILiveBrowserError = {
1202
+ code: 'internal_operation_queue_full',
1203
+ message: queueError.message,
1204
+ fatal: priority,
1205
+ ...(tabId ? { tabId } : {}),
1206
+ };
1207
+ this.emitError(liveBrowserError);
1208
+ if (priority) {
1209
+ void this.requestShutdown(liveBrowserError).catch((error) => {
1210
+ this.emitError({
1211
+ code: 'queue_overflow_shutdown_failed',
1212
+ message: normalizeErrorMessage(error),
1213
+ fatal: true,
1214
+ });
1215
+ });
1216
+ }
1217
+ return false;
1218
+ }
1219
+ void this.enqueueInternalOperation(operation, priority).catch((error) => {
1220
+ if (
1221
+ this.status === 'stopped'
1222
+ || this.status === 'stopping'
1223
+ || this.normalStopRequested
1224
+ ) {
1225
+ return;
1226
+ }
1227
+ this.emitError({
1228
+ code: errorCode,
1229
+ message: normalizeErrorMessage(error),
1230
+ fatal: false,
1231
+ ...(tabId ? { tabId } : {}),
1232
+ });
1233
+ });
1234
+ return true;
1235
+ }
1236
+
1237
+ private emitEvent(event: TLiveBrowserEvent): void {
1238
+ for (const listener of [...this.eventListeners]) {
1239
+ try {
1240
+ listener(event);
1241
+ } catch {
1242
+ // A consumer listener must not interrupt browser lifecycle cleanup.
1243
+ }
1244
+ }
1245
+ }
1246
+
1247
+ private emitState(): void {
1248
+ this.emitEvent({
1249
+ type: 'state',
1250
+ state: this.getState(),
1251
+ });
1252
+ }
1253
+
1254
+ private emitError(error: ILiveBrowserError): void {
1255
+ if (error.fatal) {
1256
+ this.lastError = { ...error };
1257
+ }
1258
+ this.emitEvent({
1259
+ type: 'error',
1260
+ error: { ...error },
1261
+ });
1262
+ this.emitState();
1263
+ }
1264
+
1265
+ private createTabState(tab: IPrivateLiveBrowserTab): ILiveBrowserTabState {
1266
+ return {
1267
+ id: tab.id,
1268
+ url: tab.url,
1269
+ title: tab.title,
1270
+ active: this.activeTabId === tab.id,
1271
+ status: tab.status,
1272
+ generation: tab.generation,
1273
+ appliedViewportRevision: tab.appliedViewportRevision,
1274
+ streaming: tab.streaming,
1275
+ };
1276
+ }
1277
+
1278
+ private async stopInternal(error?: ILiveBrowserError): Promise<void> {
1279
+ if (this.status === 'stopped') {
1280
+ return;
1281
+ }
1282
+ this.status = 'stopping';
1283
+ if (error) {
1284
+ this.lastError = { ...error };
1285
+ }
1286
+ this.emitState();
1287
+ if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
1288
+ this.browserLifetimeController.abort(new Error('LiveBrowserSession is stopping'));
1289
+ }
1290
+
1291
+ const browser = this.browser;
1292
+ if (browser && this.browserDisconnectedListener) {
1293
+ browser.off('disconnected', this.browserDisconnectedListener);
1294
+ }
1295
+ this.browserDisconnectedListener = undefined;
1296
+
1297
+ const shutdownErrors: unknown[] = [];
1298
+ for (const tab of [...this.tabs.values()]) {
1299
+ tab.closing = true;
1300
+ try {
1301
+ await this.stopScreencast(tab);
1302
+ } catch (cleanupError) {
1303
+ shutdownErrors.push(cleanupError);
1304
+ }
1305
+ try {
1306
+ this.removePageListeners(tab);
1307
+ } catch (cleanupError) {
1308
+ shutdownErrors.push(cleanupError);
1309
+ }
1310
+ }
1311
+ try {
1312
+ await this.retireOutstandingFrames(() => true);
1313
+ } catch (cleanupError) {
1314
+ shutdownErrors.push(cleanupError);
1315
+ }
1316
+
1317
+ if (browser) {
1318
+ try {
1319
+ await browser.close();
1320
+ } catch (closeError) {
1321
+ shutdownErrors.push(closeError);
1322
+ }
1323
+ }
1324
+
1325
+ for (const tab of this.tabs.values()) {
1326
+ this.tabIdsByPage.delete(tab.page);
1327
+ }
1328
+ this.tabs.clear();
1329
+ this.outstandingFrames.clear();
1330
+ this.browser = undefined;
1331
+ this.browserContext = undefined;
1332
+ this.browserLifetimeController = undefined;
1333
+ this.activeTabId = null;
1334
+ this.status = 'stopped';
1335
+ this.emitState();
1336
+ if (shutdownErrors.length > 0 && !error) {
1337
+ throw new AggregateError(shutdownErrors, 'LiveBrowserSession shutdown was incomplete');
1338
+ }
1339
+ }
1340
+
1341
+ private requireBrowserContext(): plugins.puppeteer.BrowserContext {
1342
+ if (this.status !== 'running' || !this.browserContext) {
1343
+ throw new Error('LiveBrowserSession is not running');
1344
+ }
1345
+ return this.browserContext;
1346
+ }
1347
+
1348
+ private requireTab(tabId: string): IPrivateLiveBrowserTab {
1349
+ validateBoundedString(tabId, 'tabId', 1, 128);
1350
+ const tab = this.tabs.get(tabId);
1351
+ if (!tab) {
1352
+ throw new Error(`Unknown tab: ${tabId}`);
1353
+ }
1354
+ return tab;
1355
+ }
1356
+
1357
+ private requireActiveTab(): IPrivateLiveBrowserTab {
1358
+ if (this.status !== 'running' || !this.activeTabId) {
1359
+ throw new Error('LiveBrowserSession has no active tab');
1360
+ }
1361
+ const tab = this.requireTab(this.activeTabId);
1362
+ if (tab.status !== 'open' || tab.page.isClosed()) {
1363
+ throw new Error(`Active tab is not available: ${tab.id}`);
1364
+ }
1365
+ return tab;
1366
+ }
1367
+
1368
+ private resolveActionTab(tabId?: string): IPrivateLiveBrowserTab {
1369
+ if (this.status !== 'running') {
1370
+ throw new Error('LiveBrowserSession is not running');
1371
+ }
1372
+ const tab = tabId ? this.requireTab(tabId) : this.requireActiveTab();
1373
+ if (tab.status !== 'open' || tab.page.isClosed()) {
1374
+ throw new Error(`Tab is not available: ${tab.id}`);
1375
+ }
1376
+ return tab;
1377
+ }
1378
+
1379
+ private resolveNavigationTab(tabId?: string): IPrivateLiveBrowserTab {
1380
+ return this.resolveActionTab(tabId);
1381
+ }
1382
+
1383
+ private async ensureTabViewport(tab: IPrivateLiveBrowserTab): Promise<void> {
1384
+ if (tab.appliedViewportRevision === this.viewportRevision) {
1385
+ return;
1386
+ }
1387
+ const shouldRestartScreencast = this.activeTabId === tab.id && tab.streaming;
1388
+ if (shouldRestartScreencast) {
1389
+ await this.stopScreencast(tab);
1390
+ }
1391
+ try {
1392
+ await tab.page.setViewport(createPuppeteerViewport(this.viewport));
1393
+ tab.appliedViewportRevision = this.viewportRevision;
1394
+ } finally {
1395
+ if (
1396
+ shouldRestartScreencast
1397
+ && this.status === 'running'
1398
+ && this.activeTabId === tab.id
1399
+ && tab.status === 'open'
1400
+ && !tab.page.isClosed()
1401
+ ) {
1402
+ await this.startScreencast(tab);
1403
+ }
1404
+ }
1405
+ }
1406
+
1407
+ private async registerPage(page: plugins.puppeteer.Page): Promise<IPrivateLiveBrowserTab> {
1408
+ const existingTabId = this.tabIdsByPage.get(page);
1409
+ if (existingTabId) {
1410
+ return this.requireTab(existingTabId);
1411
+ }
1412
+ if (page.isClosed()) {
1413
+ throw new Error('Cannot register a closed page');
1414
+ }
1415
+
1416
+ const tab: IPrivateLiveBrowserTab = {
1417
+ id: `tab-${++this.tabSequence}`,
1418
+ page,
1419
+ url: truncate(page.url(), 4096),
1420
+ title: '',
1421
+ status: 'open',
1422
+ generation: 0,
1423
+ appliedViewportRevision: 0,
1424
+ streaming: false,
1425
+ streamInvalidated: true,
1426
+ navigationInProgress: false,
1427
+ closing: false,
1428
+ stateUpdateQueued: false,
1429
+ stateUpdatePending: false,
1430
+ navigationResetPending: false,
1431
+ removeListeners: [],
1432
+ };
1433
+ this.tabs.set(tab.id, tab);
1434
+ this.tabIdsByPage.set(page, tab.id);
1435
+ try {
1436
+ await this.ensureTabViewport(tab);
1437
+ await this.refreshTab(tab);
1438
+
1439
+ const onPopup = (popup: plugins.puppeteer.Page | null): void => {
1440
+ if (
1441
+ !popup
1442
+ || this.normalStopRequested
1443
+ || this.status === 'stopped'
1444
+ || this.status === 'stopping'
1445
+ ) {
1446
+ return;
1447
+ }
1448
+ const popupWasScheduled = this.scheduleOperation(async () => {
1449
+ if (popup.isClosed()) {
1450
+ return;
1451
+ }
1452
+ const previousActiveTabId = this.activeTabId;
1453
+ let popupTab: IPrivateLiveBrowserTab | undefined;
1454
+ try {
1455
+ popupTab = await this.registerPage(popup);
1456
+ await this.activateTabInternal(popupTab.id);
1457
+ } catch (error) {
1458
+ const rollbackError = await this.rollbackCreatedPage(
1459
+ popup,
1460
+ popupTab,
1461
+ previousActiveTabId,
1462
+ );
1463
+ if (rollbackError) {
1464
+ throw new AggregateError([error, rollbackError], 'Popup registration rollback failed');
1465
+ }
1466
+ throw error;
1467
+ }
1468
+ }, 'popup_registration_failed', tab.id);
1469
+ if (!popupWasScheduled) {
1470
+ const popupError: ILiveBrowserError = {
1471
+ code: 'popup_registration_capacity_exceeded',
1472
+ message: 'A popup could not be admitted to the internal operation queue',
1473
+ fatal: true,
1474
+ tabId: tab.id,
1475
+ };
1476
+ this.emitError(popupError);
1477
+ void this.requestShutdown(popupError).catch((shutdownError) => {
1478
+ this.emitError({
1479
+ code: 'untracked_popup_shutdown_failed',
1480
+ message: normalizeErrorMessage(shutdownError),
1481
+ fatal: true,
1482
+ tabId: tab.id,
1483
+ });
1484
+ });
1485
+ }
1486
+ };
1487
+ const onFrameNavigated = (frame: plugins.puppeteer.Frame): void => {
1488
+ if (frame !== page.mainFrame() || tab.closing) {
1489
+ return;
1490
+ }
1491
+ const navigationReset = !tab.navigationInProgress;
1492
+ if (navigationReset) {
1493
+ tab.streamInvalidated = true;
1494
+ this.retireFramesForTabInBackground(tab.id);
1495
+ }
1496
+ this.requestTabStateUpdate(tab, navigationReset);
1497
+ };
1498
+ const onLoad = (): void => {
1499
+ this.requestTabStateUpdate(tab, false);
1500
+ };
1501
+ const onClose = (): void => {
1502
+ tab.streamInvalidated = true;
1503
+ this.retireFramesForTabInBackground(tab.id);
1504
+ if (tab.closing || this.normalStopRequested) {
1505
+ return;
1506
+ }
1507
+ this.scheduleOperation(async () => {
1508
+ await this.handleUnexpectedPageClose(tab);
1509
+ }, 'page_close_cleanup_failed', tab.id, true);
1510
+ };
1511
+ const onCrash = (error: Error): void => {
1512
+ tab.streamInvalidated = true;
1513
+ this.retireFramesForTabInBackground(tab.id);
1514
+ if (tab.closing || this.normalStopRequested) {
1515
+ return;
1516
+ }
1517
+ this.scheduleOperation(async () => {
1518
+ if (!this.tabs.has(tab.id) || tab.status === 'crashed') {
1519
+ return;
1520
+ }
1521
+ await this.stopScreencast(tab);
1522
+ tab.status = 'crashed';
1523
+ const wasActive = this.activeTabId === tab.id;
1524
+ if (wasActive) {
1525
+ this.activeTabId = null;
1526
+ }
1527
+ this.emitError({
1528
+ code: 'page_crashed',
1529
+ message: normalizeErrorMessage(error),
1530
+ fatal: false,
1531
+ tabId: tab.id,
1532
+ });
1533
+ if (wasActive) {
1534
+ await this.activateReplacementTab();
1535
+ }
1536
+ }, 'page_crash_cleanup_failed', tab.id, true);
1537
+ };
1538
+ const onPageError = (error: unknown): void => {
1539
+ if (tab.closing || this.normalStopRequested) {
1540
+ return;
1541
+ }
1542
+ this.emitError({
1543
+ code: 'page_error',
1544
+ message: normalizeErrorMessage(error),
1545
+ fatal: false,
1546
+ tabId: tab.id,
1547
+ });
1548
+ };
1549
+
1550
+ page.on('popup', onPopup);
1551
+ page.on('framenavigated', onFrameNavigated);
1552
+ page.on('load', onLoad);
1553
+ page.on('close', onClose);
1554
+ page.on('error', onCrash);
1555
+ page.on('pageerror', onPageError);
1556
+ tab.removeListeners.push(
1557
+ () => page.off('popup', onPopup),
1558
+ () => page.off('framenavigated', onFrameNavigated),
1559
+ () => page.off('load', onLoad),
1560
+ () => page.off('close', onClose),
1561
+ () => page.off('error', onCrash),
1562
+ () => page.off('pageerror', onPageError),
1563
+ );
1564
+ return tab;
1565
+ } catch (error) {
1566
+ this.removePageListeners(tab);
1567
+ this.tabs.delete(tab.id);
1568
+ this.tabIdsByPage.delete(page);
1569
+ throw error;
1570
+ }
1571
+ }
1572
+
1573
+ private removePageListeners(tab: IPrivateLiveBrowserTab): void {
1574
+ for (const removeListener of tab.removeListeners.splice(0)) {
1575
+ removeListener();
1576
+ }
1577
+ }
1578
+
1579
+ private retireFramesForTabInBackground(tabId: string): void {
1580
+ void this.retireOutstandingFrames((frame) => frame.tabId === tabId).catch((error) => {
1581
+ if (this.status === 'running' && !this.normalStopRequested) {
1582
+ this.emitError({
1583
+ code: 'frame_retirement_failed',
1584
+ message: normalizeErrorMessage(error),
1585
+ fatal: false,
1586
+ tabId,
1587
+ });
1588
+ }
1589
+ });
1590
+ }
1591
+
1592
+ private requestTabStateUpdate(
1593
+ tab: IPrivateLiveBrowserTab,
1594
+ navigationReset: boolean,
1595
+ ): void {
1596
+ if (!this.tabs.has(tab.id) || tab.closing) {
1597
+ return;
1598
+ }
1599
+ tab.stateUpdatePending = true;
1600
+ tab.navigationResetPending ||= navigationReset;
1601
+ if (tab.stateUpdateQueued) {
1602
+ return;
1603
+ }
1604
+ tab.stateUpdateQueued = true;
1605
+ const wasScheduled = this.scheduleOperation(async () => {
1606
+ try {
1607
+ const shouldResetNavigation = tab.navigationResetPending;
1608
+ tab.stateUpdatePending = false;
1609
+ tab.navigationResetPending = false;
1610
+ if (!this.tabs.has(tab.id) || tab.closing) {
1611
+ return;
1612
+ }
1613
+ if (shouldResetNavigation && this.activeTabId === tab.id) {
1614
+ await this.stopScreencast(tab);
1615
+ }
1616
+ await this.refreshTab(tab);
1617
+ this.emitState();
1618
+ if (
1619
+ shouldResetNavigation
1620
+ && this.activeTabId === tab.id
1621
+ && this.status === 'running'
1622
+ && tab.status === 'open'
1623
+ ) {
1624
+ await this.startScreencast(tab);
1625
+ }
1626
+ } finally {
1627
+ tab.stateUpdateQueued = false;
1628
+ if (
1629
+ tab.stateUpdatePending
1630
+ && this.tabs.has(tab.id)
1631
+ && this.status === 'running'
1632
+ && !tab.closing
1633
+ ) {
1634
+ this.requestTabStateUpdate(tab, false);
1635
+ }
1636
+ }
1637
+ }, 'page_state_update_failed', tab.id);
1638
+ if (!wasScheduled) {
1639
+ tab.stateUpdateQueued = false;
1640
+ }
1641
+ }
1642
+
1643
+ private async rollbackCreatedPage(
1644
+ page: plugins.puppeteer.Page,
1645
+ tab: IPrivateLiveBrowserTab | undefined,
1646
+ previousActiveTabId: string | null,
1647
+ ): Promise<Error | undefined> {
1648
+ const rollbackErrors: unknown[] = [];
1649
+ if (tab) {
1650
+ try {
1651
+ await this.stopScreencast(tab);
1652
+ } catch (error) {
1653
+ rollbackErrors.push(error);
1654
+ }
1655
+ tab.closing = true;
1656
+ }
1657
+ try {
1658
+ if (!page.isClosed()) {
1659
+ await page.close();
1660
+ }
1661
+ } catch (error) {
1662
+ rollbackErrors.push(error);
1663
+ }
1664
+ const pageClosed = page.isClosed();
1665
+ if (tab) {
1666
+ if (pageClosed) {
1667
+ this.removePageListeners(tab);
1668
+ this.tabs.delete(tab.id);
1669
+ this.tabIdsByPage.delete(tab.page);
1670
+ } else {
1671
+ tab.closing = false;
1672
+ try {
1673
+ await this.refreshTab(tab);
1674
+ } catch (error) {
1675
+ rollbackErrors.push(error);
1676
+ }
1677
+ }
1678
+ } else {
1679
+ this.tabIdsByPage.delete(page);
1680
+ if (!pageClosed) {
1681
+ const rollbackError: ILiveBrowserError = {
1682
+ code: 'untracked_page_rollback_failed',
1683
+ message: 'A failed page registration could not close its Chromium page',
1684
+ fatal: true,
1685
+ };
1686
+ if (!this.normalStopRequested && this.status === 'running') {
1687
+ this.emitError(rollbackError);
1688
+ }
1689
+ this.normalStopRequested = true;
1690
+ this.beginShutdown(false);
1691
+ try {
1692
+ await this.stopInternal();
1693
+ } catch (error) {
1694
+ rollbackErrors.push(error);
1695
+ }
1696
+ }
1697
+ }
1698
+
1699
+ const previousActiveTab = previousActiveTabId
1700
+ ? this.tabs.get(previousActiveTabId)
1701
+ : undefined;
1702
+ this.activeTabId = previousActiveTab?.id ?? null;
1703
+ if (
1704
+ previousActiveTab
1705
+ && previousActiveTab.status === 'open'
1706
+ && !previousActiveTab.page.isClosed()
1707
+ ) {
1708
+ try {
1709
+ await this.ensureTabViewport(previousActiveTab);
1710
+ await previousActiveTab.page.bringToFront();
1711
+ if (this.status === 'running' && !previousActiveTab.streaming) {
1712
+ await this.startScreencast(previousActiveTab);
1713
+ }
1714
+ } catch (error) {
1715
+ rollbackErrors.push(error);
1716
+ }
1717
+ }
1718
+ this.emitState();
1719
+ if (rollbackErrors.length === 0) {
1720
+ return undefined;
1721
+ }
1722
+ return new AggregateError(rollbackErrors, 'Failed to roll back a new tab');
1723
+ }
1724
+
1725
+ private async handleUnexpectedPageClose(tab: IPrivateLiveBrowserTab): Promise<void> {
1726
+ if (!this.tabs.has(tab.id)) {
1727
+ return;
1728
+ }
1729
+ await this.stopScreencast(tab);
1730
+ this.removePageListeners(tab);
1731
+ this.tabs.delete(tab.id);
1732
+ this.tabIdsByPage.delete(tab.page);
1733
+
1734
+ if (this.activeTabId === tab.id) {
1735
+ this.activeTabId = null;
1736
+ await this.activateReplacementTab();
1737
+ } else {
1738
+ this.emitState();
1739
+ }
1740
+ }
1741
+
1742
+ private async activateTabInternal(tabId: string): Promise<void> {
1743
+ const tab = this.requireTab(tabId);
1744
+ if (tab.status !== 'open' || tab.page.isClosed()) {
1745
+ throw new Error(`Tab is not available: ${tab.id}`);
1746
+ }
1747
+ if (this.activeTabId === tab.id && tab.streaming) {
1748
+ await tab.page.bringToFront();
1749
+ return;
1750
+ }
1751
+
1752
+ const currentTab = this.activeTabId ? this.tabs.get(this.activeTabId) : undefined;
1753
+ if (currentTab) {
1754
+ await this.stopScreencast(currentTab);
1755
+ }
1756
+ try {
1757
+ await this.ensureTabViewport(tab);
1758
+ await tab.page.bringToFront();
1759
+ this.activeTabId = tab.id;
1760
+ this.emitState();
1761
+ if (this.status === 'running') {
1762
+ await this.startScreencast(tab);
1763
+ }
1764
+ } catch (error) {
1765
+ if (currentTab && this.tabs.has(currentTab.id) && !currentTab.page.isClosed()) {
1766
+ this.activeTabId = currentTab.id;
1767
+ await this.ensureTabViewport(currentTab);
1768
+ await currentTab.page.bringToFront();
1769
+ if (this.status === 'running' && !currentTab.streaming) {
1770
+ await this.startScreencast(currentTab);
1771
+ }
1772
+ }
1773
+ this.emitState();
1774
+ throw error;
1775
+ }
1776
+ }
1777
+
1778
+ private async activateReplacementTab(): Promise<void> {
1779
+ const attemptedTabIds = new Set<string>();
1780
+ while (true) {
1781
+ const replacementTab = [...this.tabs.values()].find((tab) => (
1782
+ !attemptedTabIds.has(tab.id)
1783
+ && tab.status === 'open'
1784
+ && !tab.page.isClosed()
1785
+ ));
1786
+ if (!replacementTab) {
1787
+ this.activeTabId = null;
1788
+ this.normalStopRequested = true;
1789
+ this.beginShutdown(false);
1790
+ await this.stopInternal();
1791
+ return;
1792
+ }
1793
+
1794
+ attemptedTabIds.add(replacementTab.id);
1795
+ try {
1796
+ await this.activateTabInternal(replacementTab.id);
1797
+ return;
1798
+ } catch (error) {
1799
+ this.activeTabId = null;
1800
+ this.emitError({
1801
+ code: 'replacement_tab_activation_failed',
1802
+ message: normalizeErrorMessage(error),
1803
+ fatal: false,
1804
+ tabId: replacementTab.id,
1805
+ });
1806
+ }
1807
+ }
1808
+ }
1809
+
1810
+ private async navigateTab(
1811
+ tab: IPrivateLiveBrowserTab,
1812
+ navigation: () => Promise<void>,
1813
+ signal: AbortSignal,
1814
+ ): Promise<void> {
1815
+ const isActive = this.activeTabId === tab.id;
1816
+ tab.navigationInProgress = true;
1817
+ if (isActive) {
1818
+ await this.stopScreencast(tab);
1819
+ }
1820
+ try {
1821
+ if (signal.aborted) {
1822
+ throw signal.reason;
1823
+ }
1824
+ await navigation();
1825
+ } finally {
1826
+ tab.navigationInProgress = false;
1827
+ if (this.tabs.has(tab.id) && !tab.page.isClosed()) {
1828
+ await this.refreshTab(tab);
1829
+ this.emitState();
1830
+ if (
1831
+ isActive
1832
+ && this.activeTabId === tab.id
1833
+ && this.status === 'running'
1834
+ && tab.status === 'open'
1835
+ && !signal.aborted
1836
+ ) {
1837
+ await this.startScreencast(tab);
1838
+ }
1839
+ }
1840
+ }
1841
+ }
1842
+
1843
+ private createPuppeteerNavigationOptions(
1844
+ options: ILiveBrowserNavigationOptions,
1845
+ signal: AbortSignal,
1846
+ ): plugins.puppeteer.WaitForOptions {
1847
+ return {
1848
+ timeout: this.validateTimeout(options.timeoutMs, 30000),
1849
+ waitUntil: this.validateWaitUntil(options.waitUntil),
1850
+ signal,
1851
+ };
1852
+ }
1853
+
1854
+ private async refreshTab(tab: IPrivateLiveBrowserTab): Promise<void> {
1855
+ if (tab.page.isClosed()) {
1856
+ return;
1857
+ }
1858
+ tab.url = truncate(tab.page.url(), 4096);
1859
+ try {
1860
+ tab.title = truncate(await tab.page.title(), 1024);
1861
+ } catch (error) {
1862
+ if (!tab.page.isClosed() && !tab.closing) {
1863
+ throw error;
1864
+ }
1865
+ }
1866
+ }
1867
+
1868
+ private async startScreencast(tab: IPrivateLiveBrowserTab): Promise<void> {
1869
+ if (
1870
+ this.status !== 'running'
1871
+ || this.activeTabId !== tab.id
1872
+ || tab.status !== 'open'
1873
+ || tab.page.isClosed()
1874
+ || tab.streaming
1875
+ ) {
1876
+ return;
1877
+ }
1878
+
1879
+ await this.ensureTabViewport(tab);
1880
+
1881
+ const cdpSession = await tab.page.createCDPSession();
1882
+ const generation = tab.generation + 1;
1883
+ const frameListener: TScreencastFrameListener = (event) => {
1884
+ this.handleScreencastFrame(tab, cdpSession, generation, event);
1885
+ };
1886
+ const cdpConnection = cdpSession.connection();
1887
+ const cdpSessionDetachedListener: TCdpSessionDetachedListener = (detachedSession) => {
1888
+ if (detachedSession !== cdpSession) {
1889
+ return;
1890
+ }
1891
+ this.handlePossibleCdpDisconnection(
1892
+ tab,
1893
+ cdpSession,
1894
+ new Error('The tab CDP session disconnected'),
1895
+ );
1896
+ };
1897
+ tab.cdpSession = cdpSession;
1898
+ tab.cdpConnection = cdpConnection;
1899
+ tab.screencastFrameListener = frameListener;
1900
+ tab.cdpSessionDetachedListener = cdpSessionDetachedListener;
1901
+ tab.generation = generation;
1902
+ tab.streaming = true;
1903
+ tab.streamInvalidated = false;
1904
+ cdpSession.on('Page.screencastFrame', frameListener);
1905
+ cdpConnection?.on(
1906
+ plugins.puppeteer.CDPSessionEvent.SessionDetached,
1907
+ cdpSessionDetachedListener,
1908
+ );
1909
+
1910
+ const format = this.options.screencast?.format ?? 'jpeg';
1911
+ try {
1912
+ await cdpSession.send('Page.startScreencast', {
1913
+ format,
1914
+ quality: this.options.screencast?.quality ?? 80,
1915
+ maxWidth: this.options.screencast?.maxWidth,
1916
+ maxHeight: this.options.screencast?.maxHeight,
1917
+ everyNthFrame: this.options.screencast?.everyNthFrame ?? 1,
1918
+ });
1919
+ this.emitState();
1920
+ } catch (error) {
1921
+ tab.streaming = false;
1922
+ tab.streamInvalidated = true;
1923
+ tab.cdpSession = undefined;
1924
+ tab.cdpConnection = undefined;
1925
+ tab.screencastFrameListener = undefined;
1926
+ tab.cdpSessionDetachedListener = undefined;
1927
+ cdpSession.off('Page.screencastFrame', frameListener);
1928
+ cdpConnection?.off(
1929
+ plugins.puppeteer.CDPSessionEvent.SessionDetached,
1930
+ cdpSessionDetachedListener,
1931
+ );
1932
+ if (!cdpSession.detached) {
1933
+ try {
1934
+ await cdpSession.detach();
1935
+ } catch {
1936
+ // The target may have closed while screencast startup was failing.
1937
+ }
1938
+ }
1939
+ throw error;
1940
+ }
1941
+ }
1942
+
1943
+ private async stopScreencast(tab: IPrivateLiveBrowserTab): Promise<void> {
1944
+ const cdpSession = tab.cdpSession;
1945
+ const cdpConnection = tab.cdpConnection;
1946
+ const frameListener = tab.screencastFrameListener;
1947
+ const cdpSessionDetachedListener = tab.cdpSessionDetachedListener;
1948
+ tab.streaming = false;
1949
+ tab.streamInvalidated = true;
1950
+ tab.cdpSession = undefined;
1951
+ tab.cdpConnection = undefined;
1952
+ tab.screencastFrameListener = undefined;
1953
+ tab.cdpSessionDetachedListener = undefined;
1954
+ await this.retireOutstandingFrames((frame) => frame.tabId === tab.id);
1955
+ if (!cdpSession) {
1956
+ return;
1957
+ }
1958
+
1959
+ if (!cdpSession.detached) {
1960
+ try {
1961
+ await cdpSession.send('Page.stopScreencast');
1962
+ } catch {
1963
+ // Page close and browser disconnect detach the target before cleanup runs.
1964
+ }
1965
+ }
1966
+ await this.retireOutstandingFrames((frame) => frame.tabId === tab.id);
1967
+ if (frameListener) {
1968
+ cdpSession.off('Page.screencastFrame', frameListener);
1969
+ }
1970
+ if (cdpConnection && cdpSessionDetachedListener) {
1971
+ cdpConnection.off(
1972
+ plugins.puppeteer.CDPSessionEvent.SessionDetached,
1973
+ cdpSessionDetachedListener,
1974
+ );
1975
+ }
1976
+ if (!cdpSession.detached) {
1977
+ try {
1978
+ await cdpSession.detach();
1979
+ } catch {
1980
+ // A concurrent page close can detach the session first.
1981
+ }
1982
+ }
1983
+ }
1984
+
1985
+ private handleScreencastFrame(
1986
+ tab: IPrivateLiveBrowserTab,
1987
+ cdpSession: plugins.puppeteer.CDPSession,
1988
+ generation: number,
1989
+ event: TScreencastFrameEvent,
1990
+ ): void {
1991
+ if (
1992
+ this.status !== 'running'
1993
+ || this.activeTabId !== tab.id
1994
+ || tab.status !== 'open'
1995
+ || tab.streamInvalidated
1996
+ || !tab.streaming
1997
+ || tab.generation !== generation
1998
+ || tab.appliedViewportRevision !== this.viewportRevision
1999
+ || tab.cdpSession !== cdpSession
2000
+ ) {
2001
+ this.acknowledgeCdpFrameInBackground({
2002
+ tabId: tab.id,
2003
+ generation,
2004
+ viewportRevision: this.viewportRevision,
2005
+ cdpSessionId: event.sessionId,
2006
+ cdpSession,
2007
+ });
2008
+ return;
2009
+ }
2010
+
2011
+ const format = this.options.screencast?.format ?? 'jpeg';
2012
+ const data = new Uint8Array(plugins.Buffer.from(event.data, 'base64'));
2013
+ const dimensions = readImageDimensions(data, format, {
2014
+ width: Math.max(1, Math.round(event.metadata.deviceWidth)),
2015
+ height: Math.max(1, Math.round(event.metadata.deviceHeight)),
2016
+ });
2017
+ const sequence = ++this.frameSequence;
2018
+ const outstandingFrame: IOutstandingFrame = {
2019
+ tabId: tab.id,
2020
+ generation,
2021
+ viewportRevision: this.viewportRevision,
2022
+ cdpSessionId: event.sessionId,
2023
+ cdpSession,
2024
+ };
2025
+ while (this.outstandingFrames.size >= maxOutstandingFrames) {
2026
+ const oldestFrameEntry = this.outstandingFrames.entries().next().value as
2027
+ | [number, IOutstandingFrame]
2028
+ | undefined;
2029
+ if (!oldestFrameEntry) {
2030
+ break;
2031
+ }
2032
+ this.outstandingFrames.delete(oldestFrameEntry[0]);
2033
+ this.acknowledgeCdpFrameInBackground(oldestFrameEntry[1]);
2034
+ }
2035
+ this.outstandingFrames.set(sequence, outstandingFrame);
2036
+ const frame: ILiveBrowserFrame = {
2037
+ tabId: tab.id,
2038
+ sequence,
2039
+ generation,
2040
+ viewportRevision: this.viewportRevision,
2041
+ viewport: { ...this.viewport },
2042
+ format,
2043
+ mimeType: format === 'jpeg' ? 'image/jpeg' : 'image/png',
2044
+ ...dimensions,
2045
+ metadata: {
2046
+ offsetTop: event.metadata.offsetTop,
2047
+ pageScaleFactor: event.metadata.pageScaleFactor,
2048
+ deviceWidth: event.metadata.deviceWidth,
2049
+ deviceHeight: event.metadata.deviceHeight,
2050
+ scrollOffsetX: event.metadata.scrollOffsetX,
2051
+ scrollOffsetY: event.metadata.scrollOffsetY,
2052
+ ...(event.metadata.timestamp !== undefined
2053
+ ? { timestamp: event.metadata.timestamp }
2054
+ : {}),
2055
+ },
2056
+ data,
2057
+ };
2058
+ this.emitEvent({ type: 'frame', frame });
2059
+ }
2060
+
2061
+ private async acknowledgeCdpFrame(frame: IOutstandingFrame): Promise<boolean> {
2062
+ if (frame.cdpSession.detached) {
2063
+ this.handleFrameAcknowledgementFailure(
2064
+ frame,
2065
+ new Error('CDP session detached before frame acknowledgement'),
2066
+ );
2067
+ return false;
2068
+ }
2069
+ try {
2070
+ await frame.cdpSession.send('Page.screencastFrameAck', {
2071
+ sessionId: frame.cdpSessionId,
2072
+ });
2073
+ return true;
2074
+ } catch (error) {
2075
+ this.handleFrameAcknowledgementFailure(frame, error);
2076
+ return false;
2077
+ }
2078
+ }
2079
+
2080
+ private acknowledgeCdpFrameInBackground(frame: IOutstandingFrame): void {
2081
+ void this.acknowledgeCdpFrame(frame);
2082
+ }
2083
+
2084
+ private handleFrameAcknowledgementFailure(
2085
+ frame: IOutstandingFrame,
2086
+ error: unknown,
2087
+ ): void {
2088
+ const tab = this.tabs.get(frame.tabId);
2089
+ const isCurrentStream = Boolean(
2090
+ tab
2091
+ && tab.cdpSession === frame.cdpSession
2092
+ && !tab.streamInvalidated,
2093
+ );
2094
+ if (
2095
+ isCurrentStream
2096
+ && this.status === 'running'
2097
+ && !this.normalStopRequested
2098
+ ) {
2099
+ this.emitError({
2100
+ code: 'frame_acknowledgement_failed',
2101
+ message: normalizeErrorMessage(error),
2102
+ fatal: false,
2103
+ tabId: frame.tabId,
2104
+ });
2105
+ }
2106
+ if (tab) {
2107
+ this.handlePossibleCdpDisconnection(tab, frame.cdpSession, error);
2108
+ }
2109
+ }
2110
+
2111
+ private async retireOutstandingFrames(
2112
+ predicate: (frame: IOutstandingFrame) => boolean,
2113
+ ): Promise<void> {
2114
+ const acknowledgements: Array<Promise<boolean>> = [];
2115
+ for (const [sequence, frame] of this.outstandingFrames) {
2116
+ if (!predicate(frame)) {
2117
+ continue;
2118
+ }
2119
+ this.outstandingFrames.delete(sequence);
2120
+ acknowledgements.push(this.acknowledgeCdpFrame(frame));
2121
+ }
2122
+ await Promise.all(acknowledgements);
2123
+ }
2124
+
2125
+ private handlePossibleCdpDisconnection(
2126
+ tab: IPrivateLiveBrowserTab,
2127
+ cdpSession: plugins.puppeteer.CDPSession,
2128
+ error: unknown,
2129
+ ): void {
2130
+ if (
2131
+ this.normalStopRequested
2132
+ || this.status !== 'running'
2133
+ || tab.cdpSession !== cdpSession
2134
+ || tab.streamInvalidated
2135
+ || !cdpSession.detached
2136
+ ) {
2137
+ return;
2138
+ }
2139
+ tab.streamInvalidated = true;
2140
+ this.retireFramesForTabInBackground(tab.id);
2141
+ this.scheduleOperation(async () => {
2142
+ if (!this.tabs.has(tab.id) || tab.cdpSession !== cdpSession) {
2143
+ return;
2144
+ }
2145
+ await this.stopScreencast(tab);
2146
+ tab.status = 'crashed';
2147
+ const wasActive = this.activeTabId === tab.id;
2148
+ if (wasActive) {
2149
+ this.activeTabId = null;
2150
+ }
2151
+ this.emitError({
2152
+ code: 'cdp_disconnected',
2153
+ message: normalizeErrorMessage(error),
2154
+ fatal: false,
2155
+ tabId: tab.id,
2156
+ });
2157
+ if (wasActive) {
2158
+ await this.activateReplacementTab();
2159
+ }
2160
+ }, 'cdp_disconnect_cleanup_failed', tab.id, true);
2161
+ }
2162
+
2163
+ private requireRawInputTarget(
2164
+ input: { tabId: string; generation: number; viewportRevision: number },
2165
+ ): { tab: IPrivateLiveBrowserTab; cdpSession: plugins.puppeteer.CDPSession } {
2166
+ const tab = this.requireInputTarget(input, true);
2167
+ const cdpSession = tab.cdpSession;
2168
+ if (!tab.streaming || tab.streamInvalidated || !cdpSession || cdpSession.detached) {
2169
+ throw new Error(`Tab input transport is not available: ${tab.id}`);
2170
+ }
2171
+ return { tab, cdpSession };
2172
+ }
2173
+
2174
+ private requireSemanticActionTarget(
2175
+ input: { tabId: string; generation: number; viewportRevision: number },
2176
+ ): IPrivateLiveBrowserTab {
2177
+ return this.requireInputTarget(input, false);
2178
+ }
2179
+
2180
+ private requireInputTarget(
2181
+ input: { tabId: string; generation: number; viewportRevision: number },
2182
+ requireActive: boolean,
2183
+ ): IPrivateLiveBrowserTab {
2184
+ if (this.status !== 'running') {
2185
+ throw new Error('LiveBrowserSession is not running');
2186
+ }
2187
+ const tab = this.requireTab(input.tabId);
2188
+ if (requireActive && this.activeTabId !== tab.id) {
2189
+ throw new Error(`Tab is not active: ${tab.id}`);
2190
+ }
2191
+ if (!Number.isInteger(input.generation) || input.generation !== tab.generation) {
2192
+ throw new Error(
2193
+ `Stale tab generation ${input.generation}; current generation is ${tab.generation}`,
2194
+ );
2195
+ }
2196
+ if (input.viewportRevision !== this.viewportRevision) {
2197
+ throw new Error(
2198
+ `Stale viewport revision ${input.viewportRevision}; current revision is ${this.viewportRevision}`,
2199
+ );
2200
+ }
2201
+ if (tab.status !== 'open' || tab.page.isClosed()) {
2202
+ throw new Error(`Tab is not available: ${tab.id}`);
2203
+ }
2204
+ return tab;
2205
+ }
2206
+
2207
+ private validateCoordinates(x: number, y: number): void {
2208
+ validateFiniteNumber(x, 'x', 0, this.viewport.width);
2209
+ validateFiniteNumber(y, 'y', 0, this.viewport.height);
2210
+ if (x >= this.viewport.width) {
2211
+ throw new Error(`x must be less than viewport width ${this.viewport.width}`);
2212
+ }
2213
+ if (y >= this.viewport.height) {
2214
+ throw new Error(`y must be less than viewport height ${this.viewport.height}`);
2215
+ }
2216
+ }
2217
+
2218
+ private createModifierMask(modifiers?: ILiveBrowserModifierState): number {
2219
+ if (modifiers === undefined) {
2220
+ return 0;
2221
+ }
2222
+ if (!modifiers || typeof modifiers !== 'object' || Array.isArray(modifiers)) {
2223
+ throw new Error('modifiers must be an object');
2224
+ }
2225
+ const allowedKeys = new Set(['alt', 'control', 'meta', 'shift']);
2226
+ for (const key of Object.keys(modifiers)) {
2227
+ if (!allowedKeys.has(key)) {
2228
+ throw new Error(`Unknown modifier: ${key}`);
2229
+ }
2230
+ }
2231
+ validateOptionalBoolean(modifiers.alt, 'modifiers.alt');
2232
+ validateOptionalBoolean(modifiers.control, 'modifiers.control');
2233
+ validateOptionalBoolean(modifiers.meta, 'modifiers.meta');
2234
+ validateOptionalBoolean(modifiers.shift, 'modifiers.shift');
2235
+ return (
2236
+ (modifiers.alt ? 1 : 0)
2237
+ | (modifiers.control ? 2 : 0)
2238
+ | (modifiers.meta ? 4 : 0)
2239
+ | (modifiers.shift ? 8 : 0)
2240
+ );
2241
+ }
2242
+
2243
+ private validateUrl(url: unknown): string {
2244
+ const validatedUrl = validateBoundedString(url, 'url', 1, maxUrlLength);
2245
+ try {
2246
+ new URL(validatedUrl);
2247
+ } catch {
2248
+ throw new Error('url must be absolute');
2249
+ }
2250
+ return validatedUrl;
2251
+ }
2252
+
2253
+ private validateTimeout(timeoutMs?: number, defaultValue = 5000): number {
2254
+ if (timeoutMs === undefined) {
2255
+ return defaultValue;
2256
+ }
2257
+ return validateInteger(timeoutMs, 'timeoutMs', 1, maxTimeoutMs);
2258
+ }
2259
+
2260
+ private validateWaitUntil(waitUntil?: TLiveBrowserWaitUntil): TLiveBrowserWaitUntil {
2261
+ const validatedWaitUntil = waitUntil ?? 'load';
2262
+ if (!['load', 'domcontentloaded', 'networkidle0', 'networkidle2'].includes(validatedWaitUntil)) {
2263
+ throw new Error('waitUntil must be load, domcontentloaded, networkidle0, or networkidle2');
2264
+ }
2265
+ return validatedWaitUntil;
2266
+ }
2267
+
2268
+ private validateScreencastOptions(): void {
2269
+ const options = this.options.screencast;
2270
+ if (!options) {
2271
+ return;
2272
+ }
2273
+ if (options.format !== undefined && options.format !== 'jpeg' && options.format !== 'png') {
2274
+ throw new Error('screencast.format must be jpeg or png');
2275
+ }
2276
+ if (options.quality !== undefined) {
2277
+ validateInteger(options.quality, 'screencast.quality', 0, 100);
2278
+ }
2279
+ if (options.maxWidth !== undefined) {
2280
+ validateInteger(options.maxWidth, 'screencast.maxWidth', 1, maxViewportWidth);
2281
+ }
2282
+ if (options.maxHeight !== undefined) {
2283
+ validateInteger(options.maxHeight, 'screencast.maxHeight', 1, maxViewportHeight);
2284
+ }
2285
+ if (
2286
+ options.maxWidth !== undefined
2287
+ && options.maxHeight !== undefined
2288
+ && options.maxWidth * options.maxHeight > maxViewportPixelArea
2289
+ ) {
2290
+ throw new Error(
2291
+ `screencast pixel area must not exceed ${maxViewportPixelArea} pixels`,
2292
+ );
2293
+ }
2294
+ if (options.everyNthFrame !== undefined) {
2295
+ validateInteger(options.everyNthFrame, 'screencast.everyNthFrame', 1, 100);
2296
+ }
2297
+ }
2298
+ }