@push.rocks/smartpuppeteer 2.3.0 → 2.5.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.
@@ -1,4 +1,8 @@
1
1
  import { getEnvAwareBrowserInstance } from './smartpuppeteer.classes.smartpuppeteer.js';
2
+ import {
3
+ liveBrowserDefaultMaxOutstandingFrames,
4
+ liveBrowserMaxOutstandingFrames,
5
+ } from './smartpuppeteer.interfaces.livebrowser.js';
2
6
  import {
3
7
  delay,
4
8
  type IOwnedProcessIdentity,
@@ -17,6 +21,7 @@ import type {
17
21
  ILiveBrowserFrame,
18
22
  ILiveBrowserFrameAcknowledgement,
19
23
  ILiveBrowserFrameAcknowledgementRequest,
24
+ ILiveBrowserFrameIdentity,
20
25
  ILiveBrowserInsertTextInput,
21
26
  ILiveBrowserKeyInput,
22
27
  ILiveBrowserModifierState,
@@ -59,7 +64,8 @@ const maxSelectorLength = 4096;
59
64
  const maxTextLength = 32768;
60
65
  const maxUrlLength = 16384;
61
66
  const maxTimeoutMs = 60000;
62
- const maxOutstandingFrames = 3;
67
+ const frameAcknowledgementTimeoutMs = 5000;
68
+ const defaultFirstFrameTimeoutMs = 5000;
63
69
  const maxQueuedPublicOperations = 64;
64
70
  const maxQueuedInternalOperations = 128;
65
71
  const maxEvaluationScriptBytes = 262144;
@@ -119,6 +125,11 @@ interface IOwnedBrowserProcess {
119
125
  forceSignalled: boolean;
120
126
  }
121
127
 
128
+ interface IScreencastAuthority {
129
+ revision: number;
130
+ controller: AbortController;
131
+ }
132
+
122
133
  interface IPrivateLiveBrowserTab {
123
134
  id: string;
124
135
  page: plugins.puppeteer.Page;
@@ -129,6 +140,8 @@ interface IPrivateLiveBrowserTab {
129
140
  appliedViewportRevision: number;
130
141
  streaming: boolean;
131
142
  streamInvalidated: boolean;
143
+ streamLifecycleRevision: number;
144
+ screencastAuthority?: IScreencastAuthority;
132
145
  navigationInProgress: boolean;
133
146
  closing: boolean;
134
147
  stateUpdateQueued: boolean;
@@ -143,12 +156,17 @@ interface IPrivateLiveBrowserTab {
143
156
  removeListeners: Array<() => void>;
144
157
  }
145
158
 
146
- interface IOutstandingFrame {
159
+ interface ICdpScreencastFrame {
147
160
  tabId: string;
148
161
  generation: number;
149
162
  viewportRevision: number;
150
163
  cdpSessionId: number;
151
164
  cdpSession: plugins.puppeteer.CDPSession;
165
+ acknowledgementPromise?: Promise<boolean>;
166
+ }
167
+
168
+ interface IOutstandingFrame extends ICdpScreencastFrame {
169
+ sequence: number;
152
170
  }
153
171
 
154
172
  interface IImageDimensions {
@@ -367,6 +385,8 @@ export class LiveBrowserSession {
367
385
  private readonly tabs = new Map<string, IPrivateLiveBrowserTab>();
368
386
  private readonly tabIdsByPage = new WeakMap<plugins.puppeteer.Page, string>();
369
387
  private readonly outstandingFrames = new Map<number, IOutstandingFrame>();
388
+ private readonly cdpFramesBeingAcknowledged = new Set<ICdpScreencastFrame>();
389
+ private readonly maxOutstandingFrames: number;
370
390
 
371
391
  private browser?: plugins.puppeteer.Browser;
372
392
  private browserContext?: plugins.puppeteer.BrowserContext;
@@ -392,6 +412,7 @@ export class LiveBrowserSession {
392
412
  private admittedPublicOperations = 0;
393
413
  private admittedInternalOperations = 0;
394
414
  private shutdownPromise?: Promise<void>;
415
+ private shutdownFrameDrainPromise?: Promise<void>;
395
416
  private status: 'stopped' | 'starting' | 'running' | 'stopping' = 'stopped';
396
417
  private activeTabId: string | null = null;
397
418
  private viewport: ILiveBrowserViewport = { ...defaultViewport };
@@ -418,6 +439,16 @@ export class LiveBrowserSession {
418
439
  if (optionsArg.launchOptions && 'signal' in optionsArg.launchOptions) {
419
440
  throw new Error('LiveBrowserSession owns launch cancellation; launchOptions.signal is unsupported');
420
441
  }
442
+ if (
443
+ optionsArg.screencast !== undefined
444
+ && (
445
+ !optionsArg.screencast
446
+ || typeof optionsArg.screencast !== 'object'
447
+ || Array.isArray(optionsArg.screencast)
448
+ )
449
+ ) {
450
+ throw new Error('screencast must be an object');
451
+ }
421
452
  validateOptionalBoolean(optionsArg.allowEvaluation, 'allowEvaluation');
422
453
  for (const name of [
423
454
  'denyDownloads',
@@ -477,6 +508,8 @@ export class LiveBrowserSession {
477
508
  };
478
509
  this.viewport = { ...viewport };
479
510
  this.validateScreencastOptions();
511
+ this.maxOutstandingFrames = this.options.screencast?.maxOutstandingFrames
512
+ ?? liveBrowserDefaultMaxOutstandingFrames;
480
513
  }
481
514
 
482
515
  public onEvent(listener: TLiveBrowserEventListener): () => void {
@@ -802,6 +835,77 @@ export class LiveBrowserSession {
802
835
  };
803
836
  }
804
837
 
838
+ public refreshScreencast(
839
+ operationOptions: ILiveBrowserOperationOptions = {},
840
+ ): Promise<ILiveBrowserFrameIdentity> {
841
+ return this.enqueuePublicOperation(async (signal) => {
842
+ signal.throwIfAborted();
843
+ const tab = this.requireActiveTab();
844
+ if (!tab.streaming || tab.streamInvalidated) {
845
+ throw new Error(`Tab input transport is not available: ${tab.id}`);
846
+ }
847
+ let firstFrame: ReturnType<LiveBrowserSession['waitForScreencastFrame']> | undefined;
848
+ let timeout: ReturnType<typeof setTimeout> | undefined;
849
+ try {
850
+ const previousLifecycleRevision = tab.streamLifecycleRevision;
851
+ await this.stopScreencast(tab);
852
+ const refreshLifecycleRevision = previousLifecycleRevision + 1;
853
+ if (
854
+ !this.canRestoreScreencast(tab)
855
+ || tab.streamLifecycleRevision !== refreshLifecycleRevision
856
+ ) {
857
+ throw new Error(`Screencast lifecycle changed while refreshing tab: ${tab.id}`);
858
+ }
859
+ const authority = this.createScreencastAuthority(tab, refreshLifecycleRevision);
860
+ const generation = tab.generation + 1;
861
+ firstFrame = this.waitForScreencastFrame(
862
+ tab,
863
+ generation,
864
+ this.viewportRevision,
865
+ authority.controller.signal,
866
+ );
867
+ const refreshTimeoutMs = this.options.screencast?.firstFrameTimeoutMs
868
+ ?? defaultFirstFrameTimeoutMs;
869
+ const refreshTimeout = new Promise<never>((_resolve, reject) => {
870
+ timeout = setTimeout(() => {
871
+ reject(new Error(
872
+ `Screencast generation ${generation} did not restart and produce a frame within ${
873
+ refreshTimeoutMs
874
+ }ms`,
875
+ ));
876
+ }, refreshTimeoutMs);
877
+ });
878
+ const restartPromise = this.startScreencast(tab, authority);
879
+ const [, identity] = await Promise.race([
880
+ Promise.all([restartPromise, firstFrame.promise]),
881
+ refreshTimeout,
882
+ ]);
883
+ return identity;
884
+ } catch (error) {
885
+ if (!this.canRestoreScreencast(tab)) throw error;
886
+ const refreshError: ILiveBrowserError = {
887
+ code: 'screencast_refresh_failed',
888
+ message: normalizeErrorMessage(error),
889
+ fatal: true,
890
+ tabId: tab.id,
891
+ };
892
+ this.emitError(refreshError);
893
+ void this.requestShutdown(refreshError).catch((shutdownError) => {
894
+ this.emitError({
895
+ code: 'screencast_refresh_shutdown_failed',
896
+ message: normalizeErrorMessage(shutdownError),
897
+ fatal: true,
898
+ tabId: tab.id,
899
+ });
900
+ });
901
+ throw error;
902
+ } finally {
903
+ if (timeout) clearTimeout(timeout);
904
+ firstFrame?.cancel();
905
+ }
906
+ }, operationOptions);
907
+ }
908
+
805
909
  public async createTab(
806
910
  optionsArg: ILiveBrowserCreateTabOptions = {},
807
911
  operationOptions: ILiveBrowserOperationOptions = {},
@@ -1747,15 +1851,30 @@ export class LiveBrowserSession {
1747
1851
  this.emitState();
1748
1852
  }
1749
1853
  const shutdownError = new Error('LiveBrowserSession is stopping');
1750
- if (
1751
- abortActiveOperation
1752
- && this.activeOperation
1753
- && this.activeOperation.kind !== 'shutdown'
1754
- ) {
1755
- this.activeOperation.controller.abort(shutdownError);
1854
+ for (const tab of this.tabs.values()) {
1855
+ this.invalidateScreencast(tab, shutdownError);
1756
1856
  }
1757
- if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
1758
- this.browserLifetimeController.abort(shutdownError);
1857
+ const activeOperation = this.activeOperation;
1858
+ const shouldAbortActiveOperation = Boolean(
1859
+ abortActiveOperation
1860
+ && activeOperation
1861
+ && activeOperation.kind !== 'shutdown'
1862
+ );
1863
+ if (shouldAbortActiveOperation && activeOperation) {
1864
+ const browserLifetimeController = this.browserLifetimeController;
1865
+ const frameDrainPromise = (async () => {
1866
+ try {
1867
+ await this.retireOutstandingFrames(() => true);
1868
+ await this.waitForCdpFrameAcknowledgements();
1869
+ } finally {
1870
+ if (browserLifetimeController && !browserLifetimeController.signal.aborted) {
1871
+ browserLifetimeController.abort(shutdownError);
1872
+ }
1873
+ }
1874
+ })();
1875
+ this.shutdownFrameDrainPromise = frameDrainPromise;
1876
+ void frameDrainPromise.catch(() => {});
1877
+ activeOperation.controller.abort(shutdownError);
1759
1878
  }
1760
1879
  this.cancelQueuedOperations(shutdownError);
1761
1880
  }
@@ -2126,10 +2245,6 @@ export class LiveBrowserSession {
2126
2245
  this.lastError = { ...error };
2127
2246
  }
2128
2247
  this.emitState();
2129
- if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
2130
- this.browserLifetimeController.abort(new Error('LiveBrowserSession is stopping'));
2131
- }
2132
-
2133
2248
  const browser = this.browser;
2134
2249
  if (browser && this.browserTargetCreatedListener) {
2135
2250
  browser.off('targetcreated', this.browserTargetCreatedListener);
@@ -2141,6 +2256,15 @@ export class LiveBrowserSession {
2141
2256
  this.browserDisconnectedListener = undefined;
2142
2257
 
2143
2258
  const shutdownErrors: unknown[] = [];
2259
+ const shutdownFrameDrainPromise = this.shutdownFrameDrainPromise;
2260
+ this.shutdownFrameDrainPromise = undefined;
2261
+ if (shutdownFrameDrainPromise) {
2262
+ try {
2263
+ await shutdownFrameDrainPromise;
2264
+ } catch (cleanupError) {
2265
+ shutdownErrors.push(cleanupError);
2266
+ }
2267
+ }
2144
2268
  try {
2145
2269
  await this.teardownProxySecurity();
2146
2270
  } catch (cleanupError) {
@@ -2173,6 +2297,10 @@ export class LiveBrowserSession {
2173
2297
  } catch (cleanupError) {
2174
2298
  shutdownErrors.push(cleanupError);
2175
2299
  }
2300
+ await this.waitForCdpFrameAcknowledgements();
2301
+ if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
2302
+ this.browserLifetimeController.abort(new Error('LiveBrowserSession is stopping'));
2303
+ }
2176
2304
 
2177
2305
  if (browser) {
2178
2306
  try {
@@ -2794,6 +2922,7 @@ export class LiveBrowserSession {
2794
2922
  appliedViewportRevision: 0,
2795
2923
  streaming: false,
2796
2924
  streamInvalidated: true,
2925
+ streamLifecycleRevision: 0,
2797
2926
  navigationInProgress: false,
2798
2927
  closing: false,
2799
2928
  stateUpdateQueued: false,
@@ -2874,7 +3003,7 @@ export class LiveBrowserSession {
2874
3003
  tab.evaluationExecutionContextId = undefined;
2875
3004
  const navigationReset = !tab.navigationInProgress;
2876
3005
  if (navigationReset) {
2877
- tab.streamInvalidated = true;
3006
+ this.invalidateScreencast(tab, new Error('Page navigation invalidated the screencast'));
2878
3007
  this.retireFramesForTabInBackground(tab.id);
2879
3008
  }
2880
3009
  this.requestTabStateUpdate(tab, navigationReset);
@@ -2883,7 +3012,7 @@ export class LiveBrowserSession {
2883
3012
  this.requestTabStateUpdate(tab, false);
2884
3013
  };
2885
3014
  const onClose = (): void => {
2886
- tab.streamInvalidated = true;
3015
+ this.invalidateScreencast(tab, new Error('Page closure invalidated the screencast'));
2887
3016
  this.retireFramesForTabInBackground(tab.id);
2888
3017
  if (tab.closing || this.normalStopRequested) {
2889
3018
  return;
@@ -2893,7 +3022,7 @@ export class LiveBrowserSession {
2893
3022
  }, 'page_close_cleanup_failed', tab.id, true);
2894
3023
  };
2895
3024
  const onCrash = (error: Error): void => {
2896
- tab.streamInvalidated = true;
3025
+ this.invalidateScreencast(tab, error);
2897
3026
  this.retireFramesForTabInBackground(tab.id);
2898
3027
  if (tab.closing || this.normalStopRequested) {
2899
3028
  return;
@@ -3256,7 +3385,10 @@ export class LiveBrowserSession {
3256
3385
  }
3257
3386
  }
3258
3387
 
3259
- private async startScreencast(tab: IPrivateLiveBrowserTab): Promise<void> {
3388
+ private async startScreencast(
3389
+ tab: IPrivateLiveBrowserTab,
3390
+ authorityArg?: IScreencastAuthority,
3391
+ ): Promise<void> {
3260
3392
  if (
3261
3393
  this.status !== 'running'
3262
3394
  || this.activeTabId !== tab.id
@@ -3267,78 +3399,254 @@ export class LiveBrowserSession {
3267
3399
  return;
3268
3400
  }
3269
3401
 
3270
- await this.ensureTabViewport(tab);
3402
+ const authority = authorityArg
3403
+ ?? this.createScreencastAuthority(tab, tab.streamLifecycleRevision);
3404
+ let cdpSession: plugins.puppeteer.CDPSession | undefined;
3405
+ let cdpConnection: plugins.puppeteer.Connection | undefined;
3406
+ let frameListener: TScreencastFrameListener | undefined;
3407
+ let cdpSessionDetachedListener: TCdpSessionDetachedListener | undefined;
3408
+ try {
3409
+ this.assertScreencastAuthority(tab, authority);
3410
+ await this.waitForScreencastAuthority(this.ensureTabViewport(tab), authority);
3411
+ this.assertScreencastAuthority(tab, authority);
3271
3412
 
3272
- const cdpSession = await tab.page.createCDPSession();
3273
- this.allowOperationalCdpSessionDetach(cdpSession);
3274
- const generation = tab.generation + 1;
3275
- const frameListener: TScreencastFrameListener = (event) => {
3276
- this.handleScreencastFrame(tab, cdpSession, generation, event);
3277
- };
3278
- const cdpConnection = cdpSession.connection();
3279
- const cdpSessionDetachedListener: TCdpSessionDetachedListener = (detachedSession) => {
3280
- if (detachedSession !== cdpSession) {
3281
- return;
3282
- }
3283
- this.handlePossibleCdpDisconnection(
3284
- tab,
3285
- cdpSession,
3286
- new Error('The tab CDP session disconnected'),
3413
+ const cdpSessionPromise = tab.page.createCDPSession();
3414
+ void cdpSessionPromise.then(async (createdSession) => {
3415
+ if (
3416
+ tab.screencastAuthority === authority
3417
+ && !authority.controller.signal.aborted
3418
+ ) {
3419
+ return;
3420
+ }
3421
+ this.allowOperationalCdpSessionDetach(createdSession);
3422
+ if (!createdSession.detached) {
3423
+ try {
3424
+ await createdSession.detach();
3425
+ } catch {
3426
+ // Browser shutdown may detach a late-created session first.
3427
+ }
3428
+ }
3429
+ }).catch(() => {});
3430
+ cdpSession = await this.waitForScreencastAuthority(cdpSessionPromise, authority);
3431
+ this.allowOperationalCdpSessionDetach(cdpSession);
3432
+ this.assertScreencastAuthority(tab, authority);
3433
+ const generation = tab.generation + 1;
3434
+ frameListener = (event) => {
3435
+ this.handleScreencastFrame(tab, cdpSession!, generation, event);
3436
+ };
3437
+ cdpConnection = cdpSession.connection();
3438
+ cdpSessionDetachedListener = (detachedSession) => {
3439
+ if (detachedSession !== cdpSession) {
3440
+ return;
3441
+ }
3442
+ this.handlePossibleCdpDisconnection(
3443
+ tab,
3444
+ cdpSession!,
3445
+ new Error('The tab CDP session disconnected'),
3446
+ );
3447
+ };
3448
+ tab.cdpSession = cdpSession;
3449
+ tab.cdpConnection = cdpConnection;
3450
+ tab.screencastFrameListener = frameListener;
3451
+ tab.cdpSessionDetachedListener = cdpSessionDetachedListener;
3452
+ tab.generation = generation;
3453
+ tab.streaming = true;
3454
+ tab.streamInvalidated = false;
3455
+ cdpSession.on('Page.screencastFrame', frameListener);
3456
+ cdpConnection?.on(
3457
+ plugins.puppeteer.CDPSessionEvent.SessionDetached,
3458
+ cdpSessionDetachedListener,
3287
3459
  );
3288
- };
3289
- tab.cdpSession = cdpSession;
3290
- tab.cdpConnection = cdpConnection;
3291
- tab.screencastFrameListener = frameListener;
3292
- tab.cdpSessionDetachedListener = cdpSessionDetachedListener;
3293
- tab.generation = generation;
3294
- tab.streaming = true;
3295
- tab.streamInvalidated = false;
3296
- cdpSession.on('Page.screencastFrame', frameListener);
3297
- cdpConnection?.on(
3298
- plugins.puppeteer.CDPSessionEvent.SessionDetached,
3299
- cdpSessionDetachedListener,
3300
- );
3301
3460
 
3302
- const format = this.options.screencast?.format ?? 'jpeg';
3303
- try {
3304
- await cdpSession.send('Page.startScreencast', {
3305
- format,
3306
- quality: this.options.screencast?.quality ?? 80,
3307
- maxWidth: this.options.screencast?.maxWidth,
3308
- maxHeight: this.options.screencast?.maxHeight,
3309
- everyNthFrame: this.options.screencast?.everyNthFrame ?? 1,
3310
- });
3461
+ const format = this.options.screencast?.format ?? 'jpeg';
3462
+ await this.waitForScreencastAuthority(
3463
+ cdpSession.send('Page.startScreencast', {
3464
+ format,
3465
+ quality: this.options.screencast?.quality ?? 80,
3466
+ maxWidth: this.options.screencast?.maxWidth,
3467
+ maxHeight: this.options.screencast?.maxHeight,
3468
+ everyNthFrame: this.options.screencast?.everyNthFrame ?? 1,
3469
+ }),
3470
+ authority,
3471
+ );
3472
+ this.assertScreencastAuthority(tab, authority);
3311
3473
  this.emitState();
3312
3474
  } catch (error) {
3313
- tab.streaming = false;
3314
- tab.streamInvalidated = true;
3315
- tab.cdpSession = undefined;
3316
- tab.cdpConnection = undefined;
3317
- tab.screencastFrameListener = undefined;
3318
- tab.cdpSessionDetachedListener = undefined;
3319
- cdpSession.off('Page.screencastFrame', frameListener);
3320
- cdpConnection?.off(
3321
- plugins.puppeteer.CDPSessionEvent.SessionDetached,
3322
- cdpSessionDetachedListener,
3323
- );
3324
- if (!cdpSession.detached) {
3325
- try {
3326
- await cdpSession.detach();
3327
- } catch {
3328
- // The target may have closed while screencast startup was failing.
3475
+ if (tab.cdpSession === cdpSession) {
3476
+ tab.streaming = false;
3477
+ tab.cdpSession = undefined;
3478
+ tab.cdpConnection = undefined;
3479
+ tab.screencastFrameListener = undefined;
3480
+ tab.cdpSessionDetachedListener = undefined;
3481
+ }
3482
+ if (tab.screencastAuthority === authority) {
3483
+ this.invalidateScreencast(tab, error);
3484
+ }
3485
+ if (cdpSession) {
3486
+ if (frameListener) {
3487
+ cdpSession.off('Page.screencastFrame', frameListener);
3488
+ }
3489
+ if (cdpConnection && cdpSessionDetachedListener) {
3490
+ cdpConnection.off(
3491
+ plugins.puppeteer.CDPSessionEvent.SessionDetached,
3492
+ cdpSessionDetachedListener,
3493
+ );
3494
+ }
3495
+ await this.retireOutstandingFrames((frame) => frame.cdpSession === cdpSession);
3496
+ await this.waitForCdpFrameAcknowledgements(cdpSession);
3497
+ if (!cdpSession.detached) {
3498
+ try {
3499
+ await cdpSession.detach();
3500
+ } catch {
3501
+ // The target may have closed while screencast startup was failing.
3502
+ }
3329
3503
  }
3330
3504
  }
3331
3505
  throw error;
3332
3506
  }
3333
3507
  }
3334
3508
 
3509
+ private canRestoreScreencast(tab: IPrivateLiveBrowserTab): boolean {
3510
+ return this.status === 'running'
3511
+ && !this.normalStopRequested
3512
+ && this.activeTabId === tab.id
3513
+ && this.tabs.get(tab.id) === tab
3514
+ && tab.status === 'open'
3515
+ && !tab.closing
3516
+ && !tab.page.isClosed();
3517
+ }
3518
+
3519
+ private createScreencastAuthority(
3520
+ tab: IPrivateLiveBrowserTab,
3521
+ expectedLifecycleRevision: number,
3522
+ ): IScreencastAuthority {
3523
+ if (
3524
+ !this.canRestoreScreencast(tab)
3525
+ || tab.streaming
3526
+ || tab.streamLifecycleRevision !== expectedLifecycleRevision
3527
+ ) {
3528
+ throw new Error(`Screencast lifecycle authority is unavailable for tab: ${tab.id}`);
3529
+ }
3530
+ const authority: IScreencastAuthority = {
3531
+ revision: expectedLifecycleRevision,
3532
+ controller: new AbortController(),
3533
+ };
3534
+ tab.screencastAuthority = authority;
3535
+ return authority;
3536
+ }
3537
+
3538
+ private assertScreencastAuthority(
3539
+ tab: IPrivateLiveBrowserTab,
3540
+ authority: IScreencastAuthority,
3541
+ ): void {
3542
+ if (
3543
+ this.canRestoreScreencast(tab)
3544
+ && tab.streamLifecycleRevision === authority.revision
3545
+ && tab.screencastAuthority === authority
3546
+ && !authority.controller.signal.aborted
3547
+ ) {
3548
+ return;
3549
+ }
3550
+ throw authority.controller.signal.aborted
3551
+ ? normalizeAbortReason(authority.controller.signal)
3552
+ : new Error(`Screencast lifecycle authority was revoked for tab: ${tab.id}`);
3553
+ }
3554
+
3555
+ private waitForScreencastAuthority<T>(
3556
+ operation: Promise<T>,
3557
+ authority: IScreencastAuthority,
3558
+ ): Promise<T> {
3559
+ const signal = authority.controller.signal;
3560
+ if (signal.aborted) {
3561
+ return Promise.reject(normalizeAbortReason(signal));
3562
+ }
3563
+ return new Promise<T>((resolve, reject) => {
3564
+ let settled = false;
3565
+ const finish = (actionArg: () => void): void => {
3566
+ if (settled) return;
3567
+ settled = true;
3568
+ signal.removeEventListener('abort', handleAbort);
3569
+ actionArg();
3570
+ };
3571
+ const handleAbort = (): void => {
3572
+ finish(() => reject(normalizeAbortReason(signal)));
3573
+ };
3574
+ signal.addEventListener('abort', handleAbort, { once: true });
3575
+ operation.then(
3576
+ (value) => finish(() => resolve(value)),
3577
+ (error) => finish(() => reject(error)),
3578
+ );
3579
+ if (signal.aborted) handleAbort();
3580
+ });
3581
+ }
3582
+
3583
+ private invalidateScreencast(tab: IPrivateLiveBrowserTab, reason: unknown): void {
3584
+ tab.streamInvalidated = true;
3585
+ tab.streamLifecycleRevision += 1;
3586
+ const authority = tab.screencastAuthority;
3587
+ tab.screencastAuthority = undefined;
3588
+ if (authority && !authority.controller.signal.aborted) {
3589
+ authority.controller.abort(reason);
3590
+ }
3591
+ }
3592
+
3593
+ private waitForScreencastFrame(
3594
+ tab: IPrivateLiveBrowserTab,
3595
+ generation: number,
3596
+ viewportRevision: number,
3597
+ invalidationSignal: AbortSignal,
3598
+ ): {
3599
+ promise: Promise<ILiveBrowserFrameIdentity>;
3600
+ cancel: () => void;
3601
+ } {
3602
+ let settled = false;
3603
+ let resolvePromise!: (identityArg: ILiveBrowserFrameIdentity) => void;
3604
+ let rejectPromise!: (errorArg: unknown) => void;
3605
+ const promise = new Promise<ILiveBrowserFrameIdentity>((resolve, reject) => {
3606
+ resolvePromise = resolve;
3607
+ rejectPromise = reject;
3608
+ });
3609
+ void promise.catch(() => {});
3610
+ let unsubscribe: () => void = () => {};
3611
+ const finish = (actionArg: () => void) => {
3612
+ if (settled) return;
3613
+ settled = true;
3614
+ invalidationSignal.removeEventListener('abort', handleAbort);
3615
+ unsubscribe();
3616
+ actionArg();
3617
+ };
3618
+ const handleAbort = () => {
3619
+ finish(() => rejectPromise(normalizeAbortReason(invalidationSignal)));
3620
+ };
3621
+ unsubscribe = this.onEvent((eventArg) => {
3622
+ if (
3623
+ eventArg.type !== 'frame'
3624
+ || eventArg.frame.tabId !== tab.id
3625
+ || eventArg.frame.generation !== generation
3626
+ || eventArg.frame.viewportRevision !== viewportRevision
3627
+ ) return;
3628
+ finish(() => resolvePromise({
3629
+ tabId: eventArg.frame.tabId,
3630
+ sequence: eventArg.frame.sequence,
3631
+ generation: eventArg.frame.generation,
3632
+ viewportRevision: eventArg.frame.viewportRevision,
3633
+ }));
3634
+ });
3635
+ invalidationSignal.addEventListener('abort', handleAbort, { once: true });
3636
+ if (invalidationSignal.aborted) handleAbort();
3637
+ return {
3638
+ promise,
3639
+ cancel: () => finish(() => rejectPromise(new Error('Screencast frame wait was cancelled'))),
3640
+ };
3641
+ }
3642
+
3335
3643
  private async stopScreencast(tab: IPrivateLiveBrowserTab): Promise<void> {
3336
3644
  const cdpSession = tab.cdpSession;
3337
3645
  const cdpConnection = tab.cdpConnection;
3338
3646
  const frameListener = tab.screencastFrameListener;
3339
3647
  const cdpSessionDetachedListener = tab.cdpSessionDetachedListener;
3340
3648
  tab.streaming = false;
3341
- tab.streamInvalidated = true;
3649
+ this.invalidateScreencast(tab, new Error('Screencast stopped'));
3342
3650
  tab.cdpSession = undefined;
3343
3651
  tab.cdpConnection = undefined;
3344
3652
  tab.screencastFrameListener = undefined;
@@ -3365,6 +3673,7 @@ export class LiveBrowserSession {
3365
3673
  cdpSessionDetachedListener,
3366
3674
  );
3367
3675
  }
3676
+ await this.waitForCdpFrameAcknowledgements(cdpSession);
3368
3677
  if (!cdpSession.detached) {
3369
3678
  try {
3370
3679
  await cdpSession.detach();
@@ -3409,12 +3718,13 @@ export class LiveBrowserSession {
3409
3718
  const sequence = ++this.frameSequence;
3410
3719
  const outstandingFrame: IOutstandingFrame = {
3411
3720
  tabId: tab.id,
3721
+ sequence,
3412
3722
  generation,
3413
3723
  viewportRevision: this.viewportRevision,
3414
3724
  cdpSessionId: event.sessionId,
3415
3725
  cdpSession,
3416
3726
  };
3417
- while (this.outstandingFrames.size >= maxOutstandingFrames) {
3727
+ while (this.outstandingFrames.size >= this.maxOutstandingFrames) {
3418
3728
  const oldestFrameEntry = this.outstandingFrames.entries().next().value as
3419
3729
  | [number, IOutstandingFrame]
3420
3730
  | undefined;
@@ -3450,31 +3760,40 @@ export class LiveBrowserSession {
3450
3760
  this.emitEvent({ type: 'frame', frame });
3451
3761
  }
3452
3762
 
3453
- private async acknowledgeCdpFrame(frame: IOutstandingFrame): Promise<boolean> {
3454
- if (frame.cdpSession.detached) {
3455
- this.handleFrameAcknowledgementFailure(
3456
- frame,
3457
- new Error('CDP session detached before frame acknowledgement'),
3458
- );
3459
- return false;
3763
+ private acknowledgeCdpFrame(frame: ICdpScreencastFrame): Promise<boolean> {
3764
+ if (frame.acknowledgementPromise) {
3765
+ return frame.acknowledgementPromise;
3460
3766
  }
3461
- try {
3767
+ let resolveAcknowledgement!: (accepted: boolean) => void;
3768
+ const acknowledgementPromise = new Promise<boolean>((resolve) => {
3769
+ resolveAcknowledgement = resolve;
3770
+ });
3771
+ frame.acknowledgementPromise = acknowledgementPromise;
3772
+ this.cdpFramesBeingAcknowledged.add(frame);
3773
+ void (async (): Promise<boolean> => {
3774
+ if (frame.cdpSession.detached) {
3775
+ throw new Error('CDP session detached before frame acknowledgement');
3776
+ }
3462
3777
  await frame.cdpSession.send('Page.screencastFrameAck', {
3463
3778
  sessionId: frame.cdpSessionId,
3464
- });
3779
+ }, { timeout: frameAcknowledgementTimeoutMs });
3465
3780
  return true;
3466
- } catch (error) {
3781
+ })().then(resolveAcknowledgement, (error) => {
3467
3782
  this.handleFrameAcknowledgementFailure(frame, error);
3468
- return false;
3469
- }
3783
+ resolveAcknowledgement(false);
3784
+ });
3785
+ void acknowledgementPromise.then(() => {
3786
+ this.cdpFramesBeingAcknowledged.delete(frame);
3787
+ });
3788
+ return acknowledgementPromise;
3470
3789
  }
3471
3790
 
3472
- private acknowledgeCdpFrameInBackground(frame: IOutstandingFrame): void {
3791
+ private acknowledgeCdpFrameInBackground(frame: ICdpScreencastFrame): void {
3473
3792
  void this.acknowledgeCdpFrame(frame);
3474
3793
  }
3475
3794
 
3476
3795
  private handleFrameAcknowledgementFailure(
3477
- frame: IOutstandingFrame,
3796
+ frame: ICdpScreencastFrame,
3478
3797
  error: unknown,
3479
3798
  ): void {
3480
3799
  const tab = this.tabs.get(frame.tabId);
@@ -3514,6 +3833,15 @@ export class LiveBrowserSession {
3514
3833
  await Promise.all(acknowledgements);
3515
3834
  }
3516
3835
 
3836
+ private async waitForCdpFrameAcknowledgements(
3837
+ cdpSession?: plugins.puppeteer.CDPSession,
3838
+ ): Promise<void> {
3839
+ const acknowledgements = [...this.cdpFramesBeingAcknowledged]
3840
+ .filter((frame) => !cdpSession || frame.cdpSession === cdpSession)
3841
+ .map((frame) => frame.acknowledgementPromise!);
3842
+ await Promise.all(acknowledgements);
3843
+ }
3844
+
3517
3845
  private handlePossibleCdpDisconnection(
3518
3846
  tab: IPrivateLiveBrowserTab,
3519
3847
  cdpSession: plugins.puppeteer.CDPSession,
@@ -3528,7 +3856,7 @@ export class LiveBrowserSession {
3528
3856
  ) {
3529
3857
  return;
3530
3858
  }
3531
- tab.streamInvalidated = true;
3859
+ this.invalidateScreencast(tab, error);
3532
3860
  this.retireFramesForTabInBackground(tab.id);
3533
3861
  this.scheduleOperation(async () => {
3534
3862
  if (!this.tabs.has(tab.id) || tab.cdpSession !== cdpSession) {
@@ -3972,5 +4300,21 @@ export class LiveBrowserSession {
3972
4300
  if (options.everyNthFrame !== undefined) {
3973
4301
  validateInteger(options.everyNthFrame, 'screencast.everyNthFrame', 1, 100);
3974
4302
  }
4303
+ if (options.maxOutstandingFrames !== undefined) {
4304
+ validateInteger(
4305
+ options.maxOutstandingFrames,
4306
+ 'screencast.maxOutstandingFrames',
4307
+ 1,
4308
+ liveBrowserMaxOutstandingFrames,
4309
+ );
4310
+ }
4311
+ if (options.firstFrameTimeoutMs !== undefined) {
4312
+ validateInteger(
4313
+ options.firstFrameTimeoutMs,
4314
+ 'screencast.firstFrameTimeoutMs',
4315
+ 100,
4316
+ maxTimeoutMs,
4317
+ );
4318
+ }
3975
4319
  }
3976
4320
  }