@push.rocks/smartpuppeteer 2.4.0 → 2.6.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.
@@ -21,6 +21,7 @@ import type {
21
21
  ILiveBrowserFrame,
22
22
  ILiveBrowserFrameAcknowledgement,
23
23
  ILiveBrowserFrameAcknowledgementRequest,
24
+ ILiveBrowserFrameIdentity,
24
25
  ILiveBrowserInsertTextInput,
25
26
  ILiveBrowserKeyInput,
26
27
  ILiveBrowserModifierState,
@@ -64,6 +65,7 @@ const maxTextLength = 32768;
64
65
  const maxUrlLength = 16384;
65
66
  const maxTimeoutMs = 60000;
66
67
  const frameAcknowledgementTimeoutMs = 5000;
68
+ const defaultFirstFrameTimeoutMs = 5000;
67
69
  const maxQueuedPublicOperations = 64;
68
70
  const maxQueuedInternalOperations = 128;
69
71
  const maxEvaluationScriptBytes = 262144;
@@ -80,10 +82,12 @@ const maxProxySecuritySessions = 256;
80
82
  const maxTrackedProxyRequests = 1024;
81
83
  const maxTotalTrackedProxyRequests = 4096;
82
84
  const maxProxySecurityOperations = 2048;
85
+ const maxTrackedServiceWorkerVersions = 512;
86
+ const proxySecurityCommandTimeoutMs = 5000;
87
+ const proxySecurityCommandOptions = { timeout: proxySecurityCommandTimeoutMs } as const;
83
88
  const proxySeedTargetTypes = new Set([
84
89
  'background_page',
85
90
  'page',
86
- 'service_worker',
87
91
  'shared_worker',
88
92
  'webview',
89
93
  ]);
@@ -97,6 +101,9 @@ type TProxyAuthRequiredEvent = plugins.puppeteer.Protocol.Fetch.AuthRequiredEven
97
101
  type TProxyRequestPausedEvent = plugins.puppeteer.Protocol.Fetch.RequestPausedEvent;
98
102
  type TNetworkLoadingFinishedEvent = plugins.puppeteer.Protocol.Network.LoadingFinishedEvent;
99
103
  type TNetworkLoadingFailedEvent = plugins.puppeteer.Protocol.Network.LoadingFailedEvent;
104
+ type TServiceWorkerVersionUpdatedEvent = (
105
+ plugins.puppeteer.Protocol.ServiceWorker.WorkerVersionUpdatedEvent
106
+ );
100
107
 
101
108
  interface IProxySecuritySession {
102
109
  session: plugins.puppeteer.CDPSession;
@@ -106,6 +113,7 @@ interface IProxySecuritySession {
106
113
  fetchEnabled: boolean;
107
114
  allowLiveTargetDetach: boolean;
108
115
  ownedByProxySecurity: boolean;
116
+ retireWhenIdle: boolean;
109
117
  attemptedAuthentications: Set<string>;
110
118
  requestIdsByNetworkId: Map<string, string>;
111
119
  authRequiredListener: (event: TProxyAuthRequiredEvent) => void;
@@ -123,6 +131,11 @@ interface IOwnedBrowserProcess {
123
131
  forceSignalled: boolean;
124
132
  }
125
133
 
134
+ interface IScreencastAuthority {
135
+ revision: number;
136
+ controller: AbortController;
137
+ }
138
+
126
139
  interface IPrivateLiveBrowserTab {
127
140
  id: string;
128
141
  page: plugins.puppeteer.Page;
@@ -133,11 +146,14 @@ interface IPrivateLiveBrowserTab {
133
146
  appliedViewportRevision: number;
134
147
  streaming: boolean;
135
148
  streamInvalidated: boolean;
149
+ streamLifecycleRevision: number;
150
+ screencastAuthority?: IScreencastAuthority;
136
151
  navigationInProgress: boolean;
137
152
  closing: boolean;
138
153
  stateUpdateQueued: boolean;
139
154
  stateUpdatePending: boolean;
140
- navigationResetPending: boolean;
155
+ navigationResetRevision: number;
156
+ restoredNavigationRevision: number;
141
157
  evaluationExecutionContextId?: number;
142
158
  securityCdpSession?: plugins.puppeteer.CDPSession;
143
159
  cdpSession?: plugins.puppeteer.CDPSession;
@@ -387,8 +403,18 @@ export class LiveBrowserSession {
387
403
  private browserSecurityCdpSession?: plugins.puppeteer.CDPSession;
388
404
  private browserSecurityConnection?: plugins.puppeteer.Connection;
389
405
  private browserSecuritySessionAttachedListener?: (session: plugins.puppeteer.CDPSession) => void;
406
+ private browserSecurityOwnedSessionAttachedListener?: (
407
+ session: plugins.puppeteer.CDPSession,
408
+ ) => void;
390
409
  private browserSecuritySessionDetachedListener?: TCdpSessionDetachedListener;
410
+ private browserSecurityWorkerLifecycleCdpSession?: plugins.puppeteer.CDPSession;
411
+ private browserSecurityWorkerVersionUpdatedListener?: (
412
+ event: TServiceWorkerVersionUpdatedEvent,
413
+ ) => void;
391
414
  private readonly proxySecuritySessions = new Map<string, IProxySecuritySession>();
415
+ private readonly ownedProxySecuritySessions = new Set<plugins.puppeteer.CDPSession>();
416
+ private readonly browserSecurityParentOwnedSessions = new Set<plugins.puppeteer.CDPSession>();
417
+ private readonly serviceWorkerTargetIdsByVersionId = new Map<string, string>();
392
418
  private readonly proxySecurityOperations = new Set<Promise<void>>();
393
419
  private proxySecurityGeneration = 0;
394
420
  private proxySecurityStopping = false;
@@ -826,6 +852,77 @@ export class LiveBrowserSession {
826
852
  };
827
853
  }
828
854
 
855
+ public refreshScreencast(
856
+ operationOptions: ILiveBrowserOperationOptions = {},
857
+ ): Promise<ILiveBrowserFrameIdentity> {
858
+ return this.enqueuePublicOperation(async (signal) => {
859
+ signal.throwIfAborted();
860
+ const tab = this.requireActiveTab();
861
+ if (!tab.streaming || tab.streamInvalidated) {
862
+ throw new Error(`Tab input transport is not available: ${tab.id}`);
863
+ }
864
+ let firstFrame: ReturnType<LiveBrowserSession['waitForScreencastFrame']> | undefined;
865
+ let timeout: ReturnType<typeof setTimeout> | undefined;
866
+ try {
867
+ const previousLifecycleRevision = tab.streamLifecycleRevision;
868
+ await this.stopScreencast(tab);
869
+ const refreshLifecycleRevision = previousLifecycleRevision + 1;
870
+ if (
871
+ !this.canRestoreScreencast(tab)
872
+ || tab.streamLifecycleRevision !== refreshLifecycleRevision
873
+ ) {
874
+ throw new Error(`Screencast lifecycle changed while refreshing tab: ${tab.id}`);
875
+ }
876
+ const authority = this.createScreencastAuthority(tab, refreshLifecycleRevision);
877
+ const generation = tab.generation + 1;
878
+ firstFrame = this.waitForScreencastFrame(
879
+ tab,
880
+ generation,
881
+ this.viewportRevision,
882
+ authority.controller.signal,
883
+ );
884
+ const refreshTimeoutMs = this.options.screencast?.firstFrameTimeoutMs
885
+ ?? defaultFirstFrameTimeoutMs;
886
+ const refreshTimeout = new Promise<never>((_resolve, reject) => {
887
+ timeout = setTimeout(() => {
888
+ reject(new Error(
889
+ `Screencast generation ${generation} did not restart and produce a frame within ${
890
+ refreshTimeoutMs
891
+ }ms`,
892
+ ));
893
+ }, refreshTimeoutMs);
894
+ });
895
+ const restartPromise = this.startScreencast(tab, authority);
896
+ const [, identity] = await Promise.race([
897
+ Promise.all([restartPromise, firstFrame.promise]),
898
+ refreshTimeout,
899
+ ]);
900
+ return identity;
901
+ } catch (error) {
902
+ if (!this.canRestoreScreencast(tab)) throw error;
903
+ const refreshError: ILiveBrowserError = {
904
+ code: 'screencast_refresh_failed',
905
+ message: normalizeErrorMessage(error),
906
+ fatal: true,
907
+ tabId: tab.id,
908
+ };
909
+ this.emitError(refreshError);
910
+ void this.requestShutdown(refreshError).catch((shutdownError) => {
911
+ this.emitError({
912
+ code: 'screencast_refresh_shutdown_failed',
913
+ message: normalizeErrorMessage(shutdownError),
914
+ fatal: true,
915
+ tabId: tab.id,
916
+ });
917
+ });
918
+ throw error;
919
+ } finally {
920
+ if (timeout) clearTimeout(timeout);
921
+ firstFrame?.cancel();
922
+ }
923
+ }, operationOptions);
924
+ }
925
+
829
926
  public async createTab(
830
927
  optionsArg: ILiveBrowserCreateTabOptions = {},
831
928
  operationOptions: ILiveBrowserOperationOptions = {},
@@ -1771,6 +1868,9 @@ export class LiveBrowserSession {
1771
1868
  this.emitState();
1772
1869
  }
1773
1870
  const shutdownError = new Error('LiveBrowserSession is stopping');
1871
+ for (const tab of this.tabs.values()) {
1872
+ this.invalidateScreencast(tab, shutdownError);
1873
+ }
1774
1874
  const activeOperation = this.activeOperation;
1775
1875
  const shouldAbortActiveOperation = Boolean(
1776
1876
  abortActiveOperation
@@ -2193,7 +2293,10 @@ export class LiveBrowserSession {
2193
2293
  tab.securityCdpSession = undefined;
2194
2294
  if (securityCdpSession && !securityCdpSession.detached) {
2195
2295
  try {
2196
- await securityCdpSession.detach();
2296
+ await this.detachCdpSessionWithTimeout(
2297
+ securityCdpSession,
2298
+ 'page security session',
2299
+ );
2197
2300
  } catch {
2198
2301
  // Browser lifetime cancellation may close the target before explicit detach settles.
2199
2302
  }
@@ -2263,11 +2366,18 @@ export class LiveBrowserSession {
2263
2366
  if (!proxyCredentials) {
2264
2367
  try {
2265
2368
  if (denyPermissions) {
2266
- await cdpSession.send('Browser.grantPermissions', { permissions: [] });
2369
+ await cdpSession.send(
2370
+ 'Browser.grantPermissions',
2371
+ { permissions: [] },
2372
+ proxySecurityCommandOptions,
2373
+ );
2267
2374
  }
2268
2375
  } finally {
2269
2376
  if (!cdpSession.detached) {
2270
- await cdpSession.detach();
2377
+ await this.detachCdpSessionWithTimeout(
2378
+ cdpSession,
2379
+ 'browser permission session',
2380
+ );
2271
2381
  }
2272
2382
  }
2273
2383
  return;
@@ -2282,7 +2392,11 @@ export class LiveBrowserSession {
2282
2392
  this.browserSecurityCdpSession = cdpSession;
2283
2393
  try {
2284
2394
  if (denyPermissions) {
2285
- await cdpSession.send('Browser.grantPermissions', { permissions: [] });
2395
+ await cdpSession.send(
2396
+ 'Browser.grantPermissions',
2397
+ { permissions: [] },
2398
+ proxySecurityCommandOptions,
2399
+ );
2286
2400
  }
2287
2401
  const browserSecurityConnection = cdpSession.connection();
2288
2402
  if (!browserSecurityConnection) {
@@ -2290,13 +2404,69 @@ export class LiveBrowserSession {
2290
2404
  }
2291
2405
  this.browserSecurityConnection = browserSecurityConnection;
2292
2406
  this.browserSecuritySessionAttachedListener = (attachedSession) => {
2407
+ const setupPromise = this.configureProxySecuritySession(attachedSession, generation);
2408
+ queueMicrotask(() => {
2409
+ const securityRecord = this.proxySecuritySessions.get(attachedSession.id());
2410
+ if (
2411
+ securityRecord?.generation === generation
2412
+ && securityRecord.ownedByProxySecurity
2413
+ ) {
2414
+ return;
2415
+ }
2416
+ this.trackProxySecurityOperation(
2417
+ setupPromise,
2418
+ generation,
2419
+ 'proxy_security_session_setup_failed',
2420
+ );
2421
+ });
2422
+ };
2423
+ this.browserSecurityOwnedSessionAttachedListener = (attachedSession) => {
2424
+ const securityRecord = this.proxySecuritySessions.get(attachedSession.id());
2425
+ if (!securityRecord || securityRecord.generation !== generation) {
2426
+ return;
2427
+ }
2428
+ securityRecord.ownedByProxySecurity = true;
2429
+ this.ownedProxySecuritySessions.add(attachedSession);
2430
+ this.browserSecurityParentOwnedSessions.add(attachedSession);
2431
+ const setupPromise = securityRecord.setupPromise;
2432
+ securityRecord.setupPromise = setupPromise.then(async () => {
2433
+ try {
2434
+ await attachedSession.send(
2435
+ 'Runtime.runIfWaitingForDebugger',
2436
+ undefined,
2437
+ proxySecurityCommandOptions,
2438
+ );
2439
+ } catch (error) {
2440
+ this.removeProxySecuritySession(securityRecord);
2441
+ if (attachedSession.detached) {
2442
+ this.forgetOwnedProxySecuritySession(attachedSession);
2443
+ return;
2444
+ }
2445
+ if (this.proxySecurityStopping || this.normalStopRequested) {
2446
+ await this.detachOwnedProxySecuritySession(securityRecord);
2447
+ return;
2448
+ }
2449
+ if (
2450
+ securityRecord.targetId
2451
+ && await this.waitForProxySecurityTargetCoverage(
2452
+ securityRecord.targetId,
2453
+ generation,
2454
+ )
2455
+ ) {
2456
+ await this.detachOwnedProxySecuritySession(securityRecord);
2457
+ return;
2458
+ }
2459
+ throw error;
2460
+ }
2461
+ });
2293
2462
  this.trackProxySecurityOperation(
2294
- this.configureProxySecuritySession(attachedSession, generation),
2463
+ securityRecord.setupPromise,
2295
2464
  generation,
2296
2465
  'proxy_security_session_setup_failed',
2297
2466
  );
2298
2467
  };
2299
2468
  this.browserSecuritySessionDetachedListener = (detachedSession) => {
2469
+ this.forgetOwnedProxySecuritySession(detachedSession);
2300
2470
  if (detachedSession === cdpSession) {
2301
2471
  this.handleProxySecurityFailure(
2302
2472
  'proxy_security_browser_session_detached',
@@ -2305,6 +2475,14 @@ export class LiveBrowserSession {
2305
2475
  );
2306
2476
  return;
2307
2477
  }
2478
+ if (detachedSession === this.browserSecurityWorkerLifecycleCdpSession) {
2479
+ this.handleProxySecurityFailure(
2480
+ 'proxy_security_worker_lifecycle_detached',
2481
+ new Error('Proxy security service-worker lifecycle session detached unexpectedly'),
2482
+ generation,
2483
+ );
2484
+ return;
2485
+ }
2308
2486
  this.trackProxySecurityOperation(
2309
2487
  this.verifyProxySecuritySessionDetached(detachedSession, generation),
2310
2488
  generation,
@@ -2315,11 +2493,30 @@ export class LiveBrowserSession {
2315
2493
  'sessionattached',
2316
2494
  this.browserSecuritySessionAttachedListener,
2317
2495
  );
2496
+ cdpSession.on(
2497
+ plugins.puppeteer.CDPSessionEvent.SessionAttached,
2498
+ this.browserSecurityOwnedSessionAttachedListener,
2499
+ );
2318
2500
  browserSecurityConnection.on(
2319
2501
  'sessiondetached',
2320
2502
  this.browserSecuritySessionDetachedListener,
2321
2503
  );
2322
2504
 
2505
+ await cdpSession.send('Target.setAutoAttach', {
2506
+ autoAttach: true,
2507
+ waitForDebuggerOnStart: true,
2508
+ flatten: true,
2509
+ filter: [
2510
+ { type: 'service_worker' },
2511
+ { exclude: true },
2512
+ ],
2513
+ }, proxySecurityCommandOptions);
2514
+ await Promise.all(
2515
+ [...this.proxySecuritySessions.values()]
2516
+ .filter((record) => record.generation === generation && record.ownedByProxySecurity)
2517
+ .map((record) => record.setupPromise),
2518
+ );
2519
+
2323
2520
  for (const target of browser.targets()) {
2324
2521
  if (!proxySeedTargetTypes.has(target.type())) {
2325
2522
  continue;
@@ -2330,14 +2527,121 @@ export class LiveBrowserSession {
2330
2527
  throw new Error(`Proxy security did not observe session ${securitySession.id()}`);
2331
2528
  }
2332
2529
  securityRecord.ownedByProxySecurity = true;
2530
+ this.ownedProxySecuritySessions.add(securitySession);
2333
2531
  await securityRecord.setupPromise;
2334
2532
  }
2533
+ await this.configureProxySecurityWorkerLifecycle(cdpSession, generation);
2335
2534
  } catch (error) {
2336
2535
  await this.teardownProxySecurity();
2337
2536
  throw error;
2338
2537
  }
2339
2538
  }
2340
2539
 
2540
+ private async configureProxySecurityWorkerLifecycle(
2541
+ browserSecurityCdpSession: plugins.puppeteer.CDPSession,
2542
+ generation: number,
2543
+ ): Promise<void> {
2544
+ const lifecycleUrl = `data:text/html,<title>smartpuppeteer-proxy-security-${generation}</title>`;
2545
+ const { targetId } = await browserSecurityCdpSession.send('Target.createTarget', {
2546
+ url: lifecycleUrl,
2547
+ background: true,
2548
+ hidden: true,
2549
+ }, proxySecurityCommandOptions);
2550
+ const attachedSessions = new Map<string, plugins.puppeteer.CDPSession>();
2551
+ const captureAttachedSession = (session: plugins.puppeteer.CDPSession): void => {
2552
+ attachedSessions.set(session.id(), session);
2553
+ };
2554
+ browserSecurityCdpSession.on(
2555
+ plugins.puppeteer.CDPSessionEvent.SessionAttached,
2556
+ captureAttachedSession,
2557
+ );
2558
+ let lifecycleSession: plugins.puppeteer.CDPSession;
2559
+ try {
2560
+ const { sessionId } = await browserSecurityCdpSession.send('Target.attachToTarget', {
2561
+ targetId,
2562
+ flatten: true,
2563
+ }, proxySecurityCommandOptions);
2564
+ const attachedSession = attachedSessions.get(sessionId);
2565
+ if (!attachedSession) {
2566
+ throw new Error('Proxy security did not observe its hidden lifecycle session');
2567
+ }
2568
+ lifecycleSession = attachedSession;
2569
+ } finally {
2570
+ browserSecurityCdpSession.off(
2571
+ plugins.puppeteer.CDPSessionEvent.SessionAttached,
2572
+ captureAttachedSession,
2573
+ );
2574
+ }
2575
+
2576
+ const securityRecord = this.proxySecuritySessions.get(lifecycleSession.id());
2577
+ if (!securityRecord || !securityRecord.ownedByProxySecurity) {
2578
+ throw new Error('Proxy security did not configure its hidden lifecycle session');
2579
+ }
2580
+ await securityRecord.setupPromise;
2581
+ if (lifecycleSession.detached) {
2582
+ throw new Error('Proxy security hidden lifecycle session detached during setup');
2583
+ }
2584
+
2585
+ this.browserSecurityWorkerLifecycleCdpSession = lifecycleSession;
2586
+ this.browserSecurityWorkerVersionUpdatedListener = (event) => {
2587
+ const stoppedTargetIds = new Set<string>();
2588
+ const redundantTargetIds = new Set<string>();
2589
+ for (const version of event.versions) {
2590
+ if (version.targetId) {
2591
+ if (
2592
+ !this.serviceWorkerTargetIdsByVersionId.has(version.versionId)
2593
+ && this.serviceWorkerTargetIdsByVersionId.size >= maxTrackedServiceWorkerVersions
2594
+ ) {
2595
+ this.handleProxySecurityFailure(
2596
+ 'proxy_security_worker_tracking_capacity_exceeded',
2597
+ new Error(
2598
+ `Proxy security exceeded ${maxTrackedServiceWorkerVersions} service-worker versions`,
2599
+ ),
2600
+ generation,
2601
+ );
2602
+ return;
2603
+ }
2604
+ this.serviceWorkerTargetIdsByVersionId.set(version.versionId, version.targetId);
2605
+ }
2606
+ const knownTargetId = version.targetId
2607
+ ?? this.serviceWorkerTargetIdsByVersionId.get(version.versionId);
2608
+ if (version.status === 'redundant' && knownTargetId) {
2609
+ redundantTargetIds.add(knownTargetId);
2610
+ }
2611
+ if (version.runningStatus === 'stopped') {
2612
+ if (knownTargetId) {
2613
+ stoppedTargetIds.add(knownTargetId);
2614
+ }
2615
+ this.serviceWorkerTargetIdsByVersionId.delete(version.versionId);
2616
+ }
2617
+ }
2618
+ for (const workerSecurityRecord of [...this.proxySecuritySessions.values()]) {
2619
+ if (
2620
+ !workerSecurityRecord.ownedByProxySecurity
2621
+ || workerSecurityRecord.targetType !== 'service_worker'
2622
+ || !workerSecurityRecord.targetId
2623
+ || !this.browserSecurityParentOwnedSessions.has(workerSecurityRecord.session)
2624
+ ) {
2625
+ continue;
2626
+ }
2627
+ if (stoppedTargetIds.has(workerSecurityRecord.targetId)) {
2628
+ this.retireOwnedProxySecuritySession(workerSecurityRecord, true);
2629
+ } else if (redundantTargetIds.has(workerSecurityRecord.targetId)) {
2630
+ this.retireOwnedProxySecuritySession(workerSecurityRecord, false);
2631
+ }
2632
+ }
2633
+ };
2634
+ lifecycleSession.on(
2635
+ 'ServiceWorker.workerVersionUpdated',
2636
+ this.browserSecurityWorkerVersionUpdatedListener,
2637
+ );
2638
+ await lifecycleSession.send(
2639
+ 'ServiceWorker.enable',
2640
+ undefined,
2641
+ proxySecurityCommandOptions,
2642
+ );
2643
+ }
2644
+
2341
2645
  private configureProxySecuritySession(
2342
2646
  session: plugins.puppeteer.CDPSession,
2343
2647
  generation: number,
@@ -2400,7 +2704,7 @@ export class LiveBrowserSession {
2400
2704
  session.send('Fetch.continueWithAuth', {
2401
2705
  requestId: event.requestId,
2402
2706
  authChallengeResponse,
2403
- }).then(() => undefined),
2707
+ }, proxySecurityCommandOptions).then(() => undefined),
2404
2708
  generation,
2405
2709
  'proxy_security_protocol_failed',
2406
2710
  );
@@ -2433,7 +2737,11 @@ export class LiveBrowserSession {
2433
2737
  }
2434
2738
  }
2435
2739
  this.trackProxySecurityOperation(
2436
- session.send('Fetch.continueRequest', { requestId: event.requestId }).then(() => undefined),
2740
+ session.send(
2741
+ 'Fetch.continueRequest',
2742
+ { requestId: event.requestId },
2743
+ proxySecurityCommandOptions,
2744
+ ).then(() => undefined),
2437
2745
  generation,
2438
2746
  'proxy_security_protocol_failed',
2439
2747
  );
@@ -2445,6 +2753,13 @@ export class LiveBrowserSession {
2445
2753
  }
2446
2754
  requestIdsByNetworkId.delete(networkId);
2447
2755
  attemptedAuthentications.delete(requestId);
2756
+ if (
2757
+ proxySecuritySession.retireWhenIdle
2758
+ && attemptedAuthentications.size === 0
2759
+ && requestIdsByNetworkId.size === 0
2760
+ ) {
2761
+ this.retireOwnedProxySecuritySession(proxySecuritySession, false);
2762
+ }
2448
2763
  };
2449
2764
  const loadingFinishedListener = (event: TNetworkLoadingFinishedEvent): void => {
2450
2765
  forgetAuthentication(event.requestId);
@@ -2463,6 +2778,7 @@ export class LiveBrowserSession {
2463
2778
  fetchEnabled: false,
2464
2779
  allowLiveTargetDetach: false,
2465
2780
  ownedByProxySecurity: false,
2781
+ retireWhenIdle: false,
2466
2782
  attemptedAuthentications,
2467
2783
  requestIdsByNetworkId,
2468
2784
  authRequiredListener,
@@ -2471,12 +2787,20 @@ export class LiveBrowserSession {
2471
2787
  loadingFailedListener,
2472
2788
  setupPromise: Promise.resolve(),
2473
2789
  };
2474
- const targetInfoPromise = session.send('Target.getTargetInfo');
2475
- const networkEnablePromise = session.send('Network.enable');
2790
+ const targetInfoPromise = session.send(
2791
+ 'Target.getTargetInfo',
2792
+ undefined,
2793
+ proxySecurityCommandOptions,
2794
+ );
2795
+ const networkEnablePromise = session.send(
2796
+ 'Network.enable',
2797
+ undefined,
2798
+ proxySecurityCommandOptions,
2799
+ );
2476
2800
  const fetchEnablePromise = session.send('Fetch.enable', {
2477
2801
  handleAuthRequests: true,
2478
2802
  patterns: [{ urlPattern: '*' }],
2479
- });
2803
+ }, proxySecurityCommandOptions);
2480
2804
  const commandResultsPromise = Promise.allSettled([
2481
2805
  targetInfoPromise,
2482
2806
  networkEnablePromise,
@@ -2507,10 +2831,28 @@ export class LiveBrowserSession {
2507
2831
  proxySecuritySession.fetchEnabled = true;
2508
2832
  } catch (error) {
2509
2833
  this.removeProxySecuritySession(proxySecuritySession);
2510
- if (session.detached || this.proxySecurityStopping || this.normalStopRequested) {
2834
+ if (session.detached) {
2835
+ this.forgetOwnedProxySecuritySession(session);
2511
2836
  return;
2512
2837
  }
2513
- throw error;
2838
+ if (this.proxySecurityStopping || this.normalStopRequested) {
2839
+ await this.detachOwnedProxySecuritySession(proxySecuritySession);
2840
+ return;
2841
+ }
2842
+ if (
2843
+ proxySecuritySession.targetId
2844
+ && await this.waitForProxySecurityTargetCoverage(
2845
+ proxySecuritySession.targetId,
2846
+ generation,
2847
+ )
2848
+ ) {
2849
+ await this.detachOwnedProxySecuritySession(proxySecuritySession);
2850
+ return;
2851
+ }
2852
+ throw new Error(
2853
+ `Proxy security ${proxySecuritySession.ownedByProxySecurity ? 'owned' : 'observed'} session setup failed for ${proxySecuritySession.targetType ?? 'unknown'} target ${proxySecuritySession.targetId ?? 'unknown'}: ${normalizeErrorMessage(error)}`,
2854
+ { cause: error },
2855
+ );
2514
2856
  }
2515
2857
  })();
2516
2858
  proxySecuritySession.setupPromise = setupPromise;
@@ -2536,6 +2878,18 @@ export class LiveBrowserSession {
2536
2878
  if (!proxySecuritySession.targetId) {
2537
2879
  throw new Error(`Proxy security session detached before target identification: ${session.id()}`);
2538
2880
  }
2881
+ if (await this.waitForProxySecurityTargetCoverage(proxySecuritySession.targetId, generation)) {
2882
+ return;
2883
+ }
2884
+ throw new Error(
2885
+ `Proxy security detached from live target ${proxySecuritySession.targetId}`,
2886
+ );
2887
+ }
2888
+
2889
+ private async waitForProxySecurityTargetCoverage(
2890
+ targetId: string,
2891
+ generation: number,
2892
+ ): Promise<boolean> {
2539
2893
  for (let attempt = 0; attempt < 3; attempt += 1) {
2540
2894
  const generationSessions = [...this.proxySecuritySessions.values()].filter((candidate) => (
2541
2895
  candidate.generation === generation
@@ -2543,12 +2897,12 @@ export class LiveBrowserSession {
2543
2897
  await Promise.allSettled(generationSessions.map((candidate) => candidate.setupPromise));
2544
2898
  const hasReplacement = [...this.proxySecuritySessions.values()].some((candidate) => (
2545
2899
  candidate.generation === generation
2546
- && candidate.targetId === proxySecuritySession.targetId
2900
+ && candidate.targetId === targetId
2547
2901
  && candidate.fetchEnabled
2548
2902
  && !candidate.session.detached
2549
2903
  ));
2550
2904
  if (hasReplacement) {
2551
- return;
2905
+ return true;
2552
2906
  }
2553
2907
  if (attempt < 2) {
2554
2908
  await delay(25);
@@ -2558,12 +2912,12 @@ export class LiveBrowserSession {
2558
2912
  if (!browserSecurityCdpSession || browserSecurityCdpSession.detached) {
2559
2913
  throw new Error('Browser security CDP session detached unexpectedly');
2560
2914
  }
2561
- const { targetInfos } = await browserSecurityCdpSession.send('Target.getTargets');
2562
- if (targetInfos.some((targetInfo) => targetInfo.targetId === proxySecuritySession.targetId)) {
2563
- throw new Error(
2564
- `Proxy security detached from live target ${proxySecuritySession.targetId}`,
2565
- );
2566
- }
2915
+ const { targetInfos } = await browserSecurityCdpSession.send(
2916
+ 'Target.getTargets',
2917
+ undefined,
2918
+ proxySecurityCommandOptions,
2919
+ );
2920
+ return !targetInfos.some((targetInfo) => targetInfo.targetId === targetId);
2567
2921
  }
2568
2922
 
2569
2923
  private removeProxySecuritySession(proxySecuritySession: IProxySecuritySession): void {
@@ -2585,6 +2939,113 @@ export class LiveBrowserSession {
2585
2939
  this.proxySecuritySessions.delete(proxySecuritySession.session.id());
2586
2940
  }
2587
2941
 
2942
+ private async detachOwnedProxySecuritySession(
2943
+ proxySecuritySession: IProxySecuritySession,
2944
+ ): Promise<void> {
2945
+ if (!proxySecuritySession.ownedByProxySecurity) {
2946
+ return;
2947
+ }
2948
+ await this.detachTrackedOwnedProxySecuritySession(proxySecuritySession.session);
2949
+ }
2950
+
2951
+ private forgetOwnedProxySecuritySession(session: plugins.puppeteer.CDPSession): void {
2952
+ this.ownedProxySecuritySessions.delete(session);
2953
+ this.browserSecurityParentOwnedSessions.delete(session);
2954
+ }
2955
+
2956
+ private async detachCdpSessionWithTimeout(
2957
+ session: plugins.puppeteer.CDPSession,
2958
+ description: string,
2959
+ ): Promise<void> {
2960
+ let timeout: ReturnType<typeof setTimeout> | undefined;
2961
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
2962
+ timeout = setTimeout(() => {
2963
+ reject(new Error(
2964
+ `${description} did not detach within ${proxySecurityCommandTimeoutMs}ms`,
2965
+ ));
2966
+ }, proxySecurityCommandTimeoutMs);
2967
+ });
2968
+ try {
2969
+ await Promise.race([session.detach(), timeoutPromise]);
2970
+ } finally {
2971
+ if (timeout) {
2972
+ clearTimeout(timeout);
2973
+ }
2974
+ }
2975
+ }
2976
+
2977
+ private retireOwnedProxySecuritySession(
2978
+ proxySecuritySession: IProxySecuritySession,
2979
+ force: boolean,
2980
+ ): void {
2981
+ if (
2982
+ !proxySecuritySession.ownedByProxySecurity
2983
+ || this.proxySecuritySessions.get(proxySecuritySession.session.id())
2984
+ !== proxySecuritySession
2985
+ ) {
2986
+ return;
2987
+ }
2988
+ proxySecuritySession.retireWhenIdle = true;
2989
+ if (
2990
+ !force
2991
+ && (
2992
+ proxySecuritySession.attemptedAuthentications.size > 0
2993
+ || proxySecuritySession.requestIdsByNetworkId.size > 0
2994
+ )
2995
+ ) {
2996
+ return;
2997
+ }
2998
+ if (proxySecuritySession.targetId) {
2999
+ for (const [versionId, targetId] of this.serviceWorkerTargetIdsByVersionId) {
3000
+ if (targetId === proxySecuritySession.targetId) {
3001
+ this.serviceWorkerTargetIdsByVersionId.delete(versionId);
3002
+ }
3003
+ }
3004
+ }
3005
+ proxySecuritySession.allowLiveTargetDetach = true;
3006
+ this.removeProxySecuritySession(proxySecuritySession);
3007
+ this.trackProxySecurityOperation(
3008
+ this.detachOwnedProxySecuritySession(proxySecuritySession),
3009
+ proxySecuritySession.generation,
3010
+ 'proxy_security_worker_retirement_failed',
3011
+ );
3012
+ }
3013
+
3014
+ private async detachTrackedOwnedProxySecuritySession(
3015
+ session: plugins.puppeteer.CDPSession,
3016
+ ): Promise<void> {
3017
+ if (session.detached) {
3018
+ this.forgetOwnedProxySecuritySession(session);
3019
+ return;
3020
+ }
3021
+ try {
3022
+ if (this.browserSecurityParentOwnedSessions.has(session)) {
3023
+ const browserSecurityCdpSession = this.browserSecurityCdpSession;
3024
+ if (!browserSecurityCdpSession || browserSecurityCdpSession.detached) {
3025
+ throw new Error('Browser security parent session is unavailable');
3026
+ }
3027
+ await browserSecurityCdpSession.send('Target.detachFromTarget', {
3028
+ sessionId: session.id(),
3029
+ }, proxySecurityCommandOptions);
3030
+ } else {
3031
+ await this.detachCdpSessionWithTimeout(session, 'proxy security session');
3032
+ }
3033
+ this.forgetOwnedProxySecuritySession(session);
3034
+ } catch (error) {
3035
+ if (session.detached) {
3036
+ this.forgetOwnedProxySecuritySession(session);
3037
+ return;
3038
+ }
3039
+ if (this.proxySecurityStopping || this.normalStopRequested) {
3040
+ return;
3041
+ }
3042
+ throw new Error(
3043
+ `Failed to detach an owned proxy security session: ${normalizeErrorMessage(error)}`,
3044
+ { cause: error },
3045
+ );
3046
+ }
3047
+ }
3048
+
2588
3049
  private allowOperationalCdpSessionDetach(session: plugins.puppeteer.CDPSession): void {
2589
3050
  const proxySecuritySession = this.proxySecuritySessions.get(session.id());
2590
3051
  if (proxySecuritySession) {
@@ -2647,12 +3108,51 @@ export class LiveBrowserSession {
2647
3108
 
2648
3109
  private async teardownProxySecurity(): Promise<void> {
2649
3110
  this.proxySecurityStopping = true;
3111
+ this.serviceWorkerTargetIdsByVersionId.clear();
3112
+ const browserSecurityCdpSession = this.browserSecurityCdpSession;
3113
+ if (browserSecurityCdpSession && !browserSecurityCdpSession.detached) {
3114
+ try {
3115
+ await browserSecurityCdpSession.send('Target.setAutoAttach', {
3116
+ autoAttach: false,
3117
+ waitForDebuggerOnStart: false,
3118
+ flatten: true,
3119
+ }, proxySecurityCommandOptions);
3120
+ } catch {
3121
+ // Browser shutdown may detach the browser target before auto-attach is disabled.
3122
+ }
3123
+ }
3124
+ const workerLifecycleCdpSession = this.browserSecurityWorkerLifecycleCdpSession;
3125
+ if (workerLifecycleCdpSession && !workerLifecycleCdpSession.detached) {
3126
+ try {
3127
+ await workerLifecycleCdpSession.send(
3128
+ 'ServiceWorker.disable',
3129
+ undefined,
3130
+ proxySecurityCommandOptions,
3131
+ );
3132
+ } catch {
3133
+ // Browser shutdown can close the hidden lifecycle target first.
3134
+ }
3135
+ }
3136
+ if (workerLifecycleCdpSession && this.browserSecurityWorkerVersionUpdatedListener) {
3137
+ workerLifecycleCdpSession.off(
3138
+ 'ServiceWorker.workerVersionUpdated',
3139
+ this.browserSecurityWorkerVersionUpdatedListener,
3140
+ );
3141
+ }
3142
+ this.browserSecurityWorkerVersionUpdatedListener = undefined;
3143
+ this.browserSecurityWorkerLifecycleCdpSession = undefined;
2650
3144
  if (this.browserSecurityConnection && this.browserSecuritySessionAttachedListener) {
2651
3145
  this.browserSecurityConnection.off(
2652
3146
  'sessionattached',
2653
3147
  this.browserSecuritySessionAttachedListener,
2654
3148
  );
2655
3149
  }
3150
+ if (browserSecurityCdpSession && this.browserSecurityOwnedSessionAttachedListener) {
3151
+ browserSecurityCdpSession.off(
3152
+ plugins.puppeteer.CDPSessionEvent.SessionAttached,
3153
+ this.browserSecurityOwnedSessionAttachedListener,
3154
+ );
3155
+ }
2656
3156
  if (this.browserSecurityConnection && this.browserSecuritySessionDetachedListener) {
2657
3157
  this.browserSecurityConnection.off(
2658
3158
  'sessiondetached',
@@ -2660,28 +3160,31 @@ export class LiveBrowserSession {
2660
3160
  );
2661
3161
  }
2662
3162
  this.browserSecuritySessionAttachedListener = undefined;
3163
+ this.browserSecurityOwnedSessionAttachedListener = undefined;
2663
3164
  this.browserSecuritySessionDetachedListener = undefined;
2664
3165
  this.browserSecurityConnection = undefined;
2665
- const ownedSessions = [...this.proxySecuritySessions.values()]
2666
- .filter((record) => record.ownedByProxySecurity)
2667
- .map((record) => record.session);
3166
+ const ownedSessions = [...this.ownedProxySecuritySessions];
2668
3167
  for (const proxySecuritySession of [...this.proxySecuritySessions.values()]) {
2669
3168
  this.removeProxySecuritySession(proxySecuritySession);
2670
3169
  }
2671
3170
  for (const ownedSession of ownedSessions) {
2672
3171
  if (!ownedSession.detached) {
2673
3172
  try {
2674
- await ownedSession.detach();
3173
+ await this.detachTrackedOwnedProxySecuritySession(ownedSession);
2675
3174
  } catch {
2676
3175
  // Browser shutdown can close a target before explicit detach settles.
2677
3176
  }
2678
3177
  }
2679
3178
  }
2680
- const browserSecurityCdpSession = this.browserSecurityCdpSession;
3179
+ this.ownedProxySecuritySessions.clear();
3180
+ this.browserSecurityParentOwnedSessions.clear();
2681
3181
  this.browserSecurityCdpSession = undefined;
2682
3182
  if (browserSecurityCdpSession && !browserSecurityCdpSession.detached) {
2683
3183
  try {
2684
- await browserSecurityCdpSession.detach();
3184
+ await this.detachCdpSessionWithTimeout(
3185
+ browserSecurityCdpSession,
3186
+ 'browser security session',
3187
+ );
2685
3188
  } catch {
2686
3189
  // Browser shutdown can detach the browser target first.
2687
3190
  }
@@ -2839,11 +3342,13 @@ export class LiveBrowserSession {
2839
3342
  appliedViewportRevision: 0,
2840
3343
  streaming: false,
2841
3344
  streamInvalidated: true,
3345
+ streamLifecycleRevision: 0,
2842
3346
  navigationInProgress: false,
2843
3347
  closing: false,
2844
3348
  stateUpdateQueued: false,
2845
3349
  stateUpdatePending: false,
2846
- navigationResetPending: false,
3350
+ navigationResetRevision: 0,
3351
+ restoredNavigationRevision: 0,
2847
3352
  removeListeners: [],
2848
3353
  };
2849
3354
  this.tabs.set(tab.id, tab);
@@ -2855,11 +3360,11 @@ export class LiveBrowserSession {
2855
3360
  tab.securityCdpSession = securityCdpSession;
2856
3361
  await securityCdpSession.send('Page.enable', {
2857
3362
  enableFileChooserOpenedEvent: true,
2858
- });
3363
+ }, proxySecurityCommandOptions);
2859
3364
  await securityCdpSession.send('Page.setInterceptFileChooserDialog', {
2860
3365
  enabled: true,
2861
3366
  cancel: true,
2862
- });
3367
+ }, proxySecurityCommandOptions);
2863
3368
  }
2864
3369
  await this.ensureTabViewport(tab);
2865
3370
  await this.refreshTab(tab);
@@ -2919,7 +3424,7 @@ export class LiveBrowserSession {
2919
3424
  tab.evaluationExecutionContextId = undefined;
2920
3425
  const navigationReset = !tab.navigationInProgress;
2921
3426
  if (navigationReset) {
2922
- tab.streamInvalidated = true;
3427
+ this.invalidateScreencast(tab, new Error('Page navigation invalidated the screencast'));
2923
3428
  this.retireFramesForTabInBackground(tab.id);
2924
3429
  }
2925
3430
  this.requestTabStateUpdate(tab, navigationReset);
@@ -2928,7 +3433,7 @@ export class LiveBrowserSession {
2928
3433
  this.requestTabStateUpdate(tab, false);
2929
3434
  };
2930
3435
  const onClose = (): void => {
2931
- tab.streamInvalidated = true;
3436
+ this.invalidateScreencast(tab, new Error('Page closure invalidated the screencast'));
2932
3437
  this.retireFramesForTabInBackground(tab.id);
2933
3438
  if (tab.closing || this.normalStopRequested) {
2934
3439
  return;
@@ -2938,7 +3443,7 @@ export class LiveBrowserSession {
2938
3443
  }, 'page_close_cleanup_failed', tab.id, true);
2939
3444
  };
2940
3445
  const onCrash = (error: Error): void => {
2941
- tab.streamInvalidated = true;
3446
+ this.invalidateScreencast(tab, error);
2942
3447
  this.retireFramesForTabInBackground(tab.id);
2943
3448
  if (tab.closing || this.normalStopRequested) {
2944
3449
  return;
@@ -2994,7 +3499,10 @@ export class LiveBrowserSession {
2994
3499
  } catch (error) {
2995
3500
  if (tab.securityCdpSession && !tab.securityCdpSession.detached) {
2996
3501
  try {
2997
- await tab.securityCdpSession.detach();
3502
+ await this.detachCdpSessionWithTimeout(
3503
+ tab.securityCdpSession,
3504
+ 'page security session',
3505
+ );
2998
3506
  } catch {
2999
3507
  // The page may have closed while security setup was failing.
3000
3508
  }
@@ -3033,31 +3541,58 @@ export class LiveBrowserSession {
3033
3541
  return;
3034
3542
  }
3035
3543
  tab.stateUpdatePending = true;
3036
- tab.navigationResetPending ||= navigationReset;
3544
+ if (navigationReset) {
3545
+ tab.navigationResetRevision += 1;
3546
+ }
3037
3547
  if (tab.stateUpdateQueued) {
3038
3548
  return;
3039
3549
  }
3040
3550
  tab.stateUpdateQueued = true;
3041
3551
  const wasScheduled = this.scheduleOperation(async () => {
3042
3552
  try {
3043
- const shouldResetNavigation = tab.navigationResetPending;
3553
+ const navigationResetRevision = tab.navigationResetRevision;
3554
+ const shouldResetNavigation = (
3555
+ tab.restoredNavigationRevision < navigationResetRevision
3556
+ );
3044
3557
  tab.stateUpdatePending = false;
3045
- tab.navigationResetPending = false;
3046
3558
  if (!this.tabs.has(tab.id) || tab.closing) {
3047
3559
  return;
3048
3560
  }
3049
3561
  if (shouldResetNavigation && this.activeTabId === tab.id) {
3050
3562
  await this.stopScreencast(tab);
3051
3563
  }
3052
- await this.refreshTab(tab);
3053
- this.emitState();
3564
+ let stateUpdateError: unknown;
3565
+ try {
3566
+ await this.refreshTab(tab);
3567
+ this.emitState();
3568
+ } catch (error) {
3569
+ stateUpdateError = error;
3570
+ }
3054
3571
  if (
3055
3572
  shouldResetNavigation
3056
3573
  && this.activeTabId === tab.id
3057
3574
  && this.status === 'running'
3058
3575
  && tab.status === 'open'
3059
3576
  ) {
3060
- await this.startScreencast(tab);
3577
+ try {
3578
+ await this.startScreencast(tab);
3579
+ } catch (error) {
3580
+ throw stateUpdateError
3581
+ ? new AggregateError(
3582
+ [stateUpdateError, error],
3583
+ 'Page state refresh and screencast restart failed',
3584
+ )
3585
+ : error;
3586
+ }
3587
+ }
3588
+ if (shouldResetNavigation) {
3589
+ tab.restoredNavigationRevision = Math.max(
3590
+ tab.restoredNavigationRevision,
3591
+ navigationResetRevision,
3592
+ );
3593
+ }
3594
+ if (stateUpdateError) {
3595
+ throw stateUpdateError;
3061
3596
  }
3062
3597
  } finally {
3063
3598
  tab.stateUpdateQueued = false;
@@ -3262,15 +3797,32 @@ export class LiveBrowserSession {
3262
3797
  } finally {
3263
3798
  tab.navigationInProgress = false;
3264
3799
  if (this.tabs.has(tab.id) && !tab.page.isClosed()) {
3265
- await this.refreshTab(tab);
3266
- this.emitState();
3800
+ let stateUpdateError: unknown;
3801
+ try {
3802
+ await this.refreshTab(tab);
3803
+ this.emitState();
3804
+ } catch (error) {
3805
+ stateUpdateError = error;
3806
+ }
3267
3807
  if (
3268
3808
  isActive
3269
3809
  && this.activeTabId === tab.id
3270
3810
  && this.status === 'running'
3271
3811
  && tab.status === 'open'
3272
3812
  ) {
3273
- await this.startScreencast(tab);
3813
+ try {
3814
+ await this.startScreencast(tab);
3815
+ } catch (error) {
3816
+ throw stateUpdateError
3817
+ ? new AggregateError(
3818
+ [stateUpdateError, error],
3819
+ 'Navigation state refresh and screencast restart failed',
3820
+ )
3821
+ : error;
3822
+ }
3823
+ }
3824
+ if (stateUpdateError) {
3825
+ throw stateUpdateError;
3274
3826
  }
3275
3827
  }
3276
3828
  }
@@ -3301,7 +3853,10 @@ export class LiveBrowserSession {
3301
3853
  }
3302
3854
  }
3303
3855
 
3304
- private async startScreencast(tab: IPrivateLiveBrowserTab): Promise<void> {
3856
+ private async startScreencast(
3857
+ tab: IPrivateLiveBrowserTab,
3858
+ authorityArg?: IScreencastAuthority,
3859
+ ): Promise<void> {
3305
3860
  if (
3306
3861
  this.status !== 'running'
3307
3862
  || this.activeTabId !== tab.id
@@ -3312,80 +3867,254 @@ export class LiveBrowserSession {
3312
3867
  return;
3313
3868
  }
3314
3869
 
3315
- await this.ensureTabViewport(tab);
3870
+ const authority = authorityArg
3871
+ ?? this.createScreencastAuthority(tab, tab.streamLifecycleRevision);
3872
+ let cdpSession: plugins.puppeteer.CDPSession | undefined;
3873
+ let cdpConnection: plugins.puppeteer.Connection | undefined;
3874
+ let frameListener: TScreencastFrameListener | undefined;
3875
+ let cdpSessionDetachedListener: TCdpSessionDetachedListener | undefined;
3876
+ try {
3877
+ this.assertScreencastAuthority(tab, authority);
3878
+ await this.waitForScreencastAuthority(this.ensureTabViewport(tab), authority);
3879
+ this.assertScreencastAuthority(tab, authority);
3316
3880
 
3317
- const cdpSession = await tab.page.createCDPSession();
3318
- this.allowOperationalCdpSessionDetach(cdpSession);
3319
- const generation = tab.generation + 1;
3320
- const frameListener: TScreencastFrameListener = (event) => {
3321
- this.handleScreencastFrame(tab, cdpSession, generation, event);
3322
- };
3323
- const cdpConnection = cdpSession.connection();
3324
- const cdpSessionDetachedListener: TCdpSessionDetachedListener = (detachedSession) => {
3325
- if (detachedSession !== cdpSession) {
3326
- return;
3327
- }
3328
- this.handlePossibleCdpDisconnection(
3329
- tab,
3330
- cdpSession,
3331
- new Error('The tab CDP session disconnected'),
3881
+ const cdpSessionPromise = tab.page.createCDPSession();
3882
+ void cdpSessionPromise.then(async (createdSession) => {
3883
+ if (
3884
+ tab.screencastAuthority === authority
3885
+ && !authority.controller.signal.aborted
3886
+ ) {
3887
+ return;
3888
+ }
3889
+ this.allowOperationalCdpSessionDetach(createdSession);
3890
+ if (!createdSession.detached) {
3891
+ try {
3892
+ await createdSession.detach();
3893
+ } catch {
3894
+ // Browser shutdown may detach a late-created session first.
3895
+ }
3896
+ }
3897
+ }).catch(() => {});
3898
+ cdpSession = await this.waitForScreencastAuthority(cdpSessionPromise, authority);
3899
+ this.allowOperationalCdpSessionDetach(cdpSession);
3900
+ this.assertScreencastAuthority(tab, authority);
3901
+ const generation = tab.generation + 1;
3902
+ frameListener = (event) => {
3903
+ this.handleScreencastFrame(tab, cdpSession!, generation, event);
3904
+ };
3905
+ cdpConnection = cdpSession.connection();
3906
+ cdpSessionDetachedListener = (detachedSession) => {
3907
+ if (detachedSession !== cdpSession) {
3908
+ return;
3909
+ }
3910
+ this.handlePossibleCdpDisconnection(
3911
+ tab,
3912
+ cdpSession!,
3913
+ new Error('The tab CDP session disconnected'),
3914
+ );
3915
+ };
3916
+ tab.cdpSession = cdpSession;
3917
+ tab.cdpConnection = cdpConnection;
3918
+ tab.screencastFrameListener = frameListener;
3919
+ tab.cdpSessionDetachedListener = cdpSessionDetachedListener;
3920
+ tab.generation = generation;
3921
+ tab.streaming = true;
3922
+ tab.streamInvalidated = false;
3923
+ cdpSession.on('Page.screencastFrame', frameListener);
3924
+ cdpConnection?.on(
3925
+ plugins.puppeteer.CDPSessionEvent.SessionDetached,
3926
+ cdpSessionDetachedListener,
3332
3927
  );
3333
- };
3334
- tab.cdpSession = cdpSession;
3335
- tab.cdpConnection = cdpConnection;
3336
- tab.screencastFrameListener = frameListener;
3337
- tab.cdpSessionDetachedListener = cdpSessionDetachedListener;
3338
- tab.generation = generation;
3339
- tab.streaming = true;
3340
- tab.streamInvalidated = false;
3341
- cdpSession.on('Page.screencastFrame', frameListener);
3342
- cdpConnection?.on(
3343
- plugins.puppeteer.CDPSessionEvent.SessionDetached,
3344
- cdpSessionDetachedListener,
3345
- );
3346
3928
 
3347
- const format = this.options.screencast?.format ?? 'jpeg';
3348
- try {
3349
- await cdpSession.send('Page.startScreencast', {
3350
- format,
3351
- quality: this.options.screencast?.quality ?? 80,
3352
- maxWidth: this.options.screencast?.maxWidth,
3353
- maxHeight: this.options.screencast?.maxHeight,
3354
- everyNthFrame: this.options.screencast?.everyNthFrame ?? 1,
3355
- });
3929
+ const format = this.options.screencast?.format ?? 'jpeg';
3930
+ await this.waitForScreencastAuthority(
3931
+ cdpSession.send('Page.startScreencast', {
3932
+ format,
3933
+ quality: this.options.screencast?.quality ?? 80,
3934
+ maxWidth: this.options.screencast?.maxWidth,
3935
+ maxHeight: this.options.screencast?.maxHeight,
3936
+ everyNthFrame: this.options.screencast?.everyNthFrame ?? 1,
3937
+ }),
3938
+ authority,
3939
+ );
3940
+ this.assertScreencastAuthority(tab, authority);
3356
3941
  this.emitState();
3357
3942
  } catch (error) {
3358
- tab.streaming = false;
3359
- tab.streamInvalidated = true;
3360
- tab.cdpSession = undefined;
3361
- tab.cdpConnection = undefined;
3362
- tab.screencastFrameListener = undefined;
3363
- tab.cdpSessionDetachedListener = undefined;
3364
- cdpSession.off('Page.screencastFrame', frameListener);
3365
- cdpConnection?.off(
3366
- plugins.puppeteer.CDPSessionEvent.SessionDetached,
3367
- cdpSessionDetachedListener,
3368
- );
3369
- await this.retireOutstandingFrames((frame) => frame.cdpSession === cdpSession);
3370
- await this.waitForCdpFrameAcknowledgements(cdpSession);
3371
- if (!cdpSession.detached) {
3372
- try {
3373
- await cdpSession.detach();
3374
- } catch {
3375
- // The target may have closed while screencast startup was failing.
3943
+ if (tab.cdpSession === cdpSession) {
3944
+ tab.streaming = false;
3945
+ tab.cdpSession = undefined;
3946
+ tab.cdpConnection = undefined;
3947
+ tab.screencastFrameListener = undefined;
3948
+ tab.cdpSessionDetachedListener = undefined;
3949
+ }
3950
+ if (tab.screencastAuthority === authority) {
3951
+ this.invalidateScreencast(tab, error);
3952
+ }
3953
+ if (cdpSession) {
3954
+ if (frameListener) {
3955
+ cdpSession.off('Page.screencastFrame', frameListener);
3956
+ }
3957
+ if (cdpConnection && cdpSessionDetachedListener) {
3958
+ cdpConnection.off(
3959
+ plugins.puppeteer.CDPSessionEvent.SessionDetached,
3960
+ cdpSessionDetachedListener,
3961
+ );
3962
+ }
3963
+ await this.retireOutstandingFrames((frame) => frame.cdpSession === cdpSession);
3964
+ await this.waitForCdpFrameAcknowledgements(cdpSession);
3965
+ if (!cdpSession.detached) {
3966
+ try {
3967
+ await cdpSession.detach();
3968
+ } catch {
3969
+ // The target may have closed while screencast startup was failing.
3970
+ }
3376
3971
  }
3377
3972
  }
3378
3973
  throw error;
3379
3974
  }
3380
3975
  }
3381
3976
 
3977
+ private canRestoreScreencast(tab: IPrivateLiveBrowserTab): boolean {
3978
+ return this.status === 'running'
3979
+ && !this.normalStopRequested
3980
+ && this.activeTabId === tab.id
3981
+ && this.tabs.get(tab.id) === tab
3982
+ && tab.status === 'open'
3983
+ && !tab.closing
3984
+ && !tab.page.isClosed();
3985
+ }
3986
+
3987
+ private createScreencastAuthority(
3988
+ tab: IPrivateLiveBrowserTab,
3989
+ expectedLifecycleRevision: number,
3990
+ ): IScreencastAuthority {
3991
+ if (
3992
+ !this.canRestoreScreencast(tab)
3993
+ || tab.streaming
3994
+ || tab.streamLifecycleRevision !== expectedLifecycleRevision
3995
+ ) {
3996
+ throw new Error(`Screencast lifecycle authority is unavailable for tab: ${tab.id}`);
3997
+ }
3998
+ const authority: IScreencastAuthority = {
3999
+ revision: expectedLifecycleRevision,
4000
+ controller: new AbortController(),
4001
+ };
4002
+ tab.screencastAuthority = authority;
4003
+ return authority;
4004
+ }
4005
+
4006
+ private assertScreencastAuthority(
4007
+ tab: IPrivateLiveBrowserTab,
4008
+ authority: IScreencastAuthority,
4009
+ ): void {
4010
+ if (
4011
+ this.canRestoreScreencast(tab)
4012
+ && tab.streamLifecycleRevision === authority.revision
4013
+ && tab.screencastAuthority === authority
4014
+ && !authority.controller.signal.aborted
4015
+ ) {
4016
+ return;
4017
+ }
4018
+ throw authority.controller.signal.aborted
4019
+ ? normalizeAbortReason(authority.controller.signal)
4020
+ : new Error(`Screencast lifecycle authority was revoked for tab: ${tab.id}`);
4021
+ }
4022
+
4023
+ private waitForScreencastAuthority<T>(
4024
+ operation: Promise<T>,
4025
+ authority: IScreencastAuthority,
4026
+ ): Promise<T> {
4027
+ const signal = authority.controller.signal;
4028
+ if (signal.aborted) {
4029
+ return Promise.reject(normalizeAbortReason(signal));
4030
+ }
4031
+ return new Promise<T>((resolve, reject) => {
4032
+ let settled = false;
4033
+ const finish = (actionArg: () => void): void => {
4034
+ if (settled) return;
4035
+ settled = true;
4036
+ signal.removeEventListener('abort', handleAbort);
4037
+ actionArg();
4038
+ };
4039
+ const handleAbort = (): void => {
4040
+ finish(() => reject(normalizeAbortReason(signal)));
4041
+ };
4042
+ signal.addEventListener('abort', handleAbort, { once: true });
4043
+ operation.then(
4044
+ (value) => finish(() => resolve(value)),
4045
+ (error) => finish(() => reject(error)),
4046
+ );
4047
+ if (signal.aborted) handleAbort();
4048
+ });
4049
+ }
4050
+
4051
+ private invalidateScreencast(tab: IPrivateLiveBrowserTab, reason: unknown): void {
4052
+ tab.streamInvalidated = true;
4053
+ tab.streamLifecycleRevision += 1;
4054
+ const authority = tab.screencastAuthority;
4055
+ tab.screencastAuthority = undefined;
4056
+ if (authority && !authority.controller.signal.aborted) {
4057
+ authority.controller.abort(reason);
4058
+ }
4059
+ }
4060
+
4061
+ private waitForScreencastFrame(
4062
+ tab: IPrivateLiveBrowserTab,
4063
+ generation: number,
4064
+ viewportRevision: number,
4065
+ invalidationSignal: AbortSignal,
4066
+ ): {
4067
+ promise: Promise<ILiveBrowserFrameIdentity>;
4068
+ cancel: () => void;
4069
+ } {
4070
+ let settled = false;
4071
+ let resolvePromise!: (identityArg: ILiveBrowserFrameIdentity) => void;
4072
+ let rejectPromise!: (errorArg: unknown) => void;
4073
+ const promise = new Promise<ILiveBrowserFrameIdentity>((resolve, reject) => {
4074
+ resolvePromise = resolve;
4075
+ rejectPromise = reject;
4076
+ });
4077
+ void promise.catch(() => {});
4078
+ let unsubscribe: () => void = () => {};
4079
+ const finish = (actionArg: () => void) => {
4080
+ if (settled) return;
4081
+ settled = true;
4082
+ invalidationSignal.removeEventListener('abort', handleAbort);
4083
+ unsubscribe();
4084
+ actionArg();
4085
+ };
4086
+ const handleAbort = () => {
4087
+ finish(() => rejectPromise(normalizeAbortReason(invalidationSignal)));
4088
+ };
4089
+ unsubscribe = this.onEvent((eventArg) => {
4090
+ if (
4091
+ eventArg.type !== 'frame'
4092
+ || eventArg.frame.tabId !== tab.id
4093
+ || eventArg.frame.generation !== generation
4094
+ || eventArg.frame.viewportRevision !== viewportRevision
4095
+ ) return;
4096
+ finish(() => resolvePromise({
4097
+ tabId: eventArg.frame.tabId,
4098
+ sequence: eventArg.frame.sequence,
4099
+ generation: eventArg.frame.generation,
4100
+ viewportRevision: eventArg.frame.viewportRevision,
4101
+ }));
4102
+ });
4103
+ invalidationSignal.addEventListener('abort', handleAbort, { once: true });
4104
+ if (invalidationSignal.aborted) handleAbort();
4105
+ return {
4106
+ promise,
4107
+ cancel: () => finish(() => rejectPromise(new Error('Screencast frame wait was cancelled'))),
4108
+ };
4109
+ }
4110
+
3382
4111
  private async stopScreencast(tab: IPrivateLiveBrowserTab): Promise<void> {
3383
4112
  const cdpSession = tab.cdpSession;
3384
4113
  const cdpConnection = tab.cdpConnection;
3385
4114
  const frameListener = tab.screencastFrameListener;
3386
4115
  const cdpSessionDetachedListener = tab.cdpSessionDetachedListener;
3387
4116
  tab.streaming = false;
3388
- tab.streamInvalidated = true;
4117
+ this.invalidateScreencast(tab, new Error('Screencast stopped'));
3389
4118
  tab.cdpSession = undefined;
3390
4119
  tab.cdpConnection = undefined;
3391
4120
  tab.screencastFrameListener = undefined;
@@ -3595,7 +4324,7 @@ export class LiveBrowserSession {
3595
4324
  ) {
3596
4325
  return;
3597
4326
  }
3598
- tab.streamInvalidated = true;
4327
+ this.invalidateScreencast(tab, error);
3599
4328
  this.retireFramesForTabInBackground(tab.id);
3600
4329
  this.scheduleOperation(async () => {
3601
4330
  if (!this.tabs.has(tab.id) || tab.cdpSession !== cdpSession) {
@@ -4047,5 +4776,13 @@ export class LiveBrowserSession {
4047
4776
  liveBrowserMaxOutstandingFrames,
4048
4777
  );
4049
4778
  }
4779
+ if (options.firstFrameTimeoutMs !== undefined) {
4780
+ validateInteger(
4781
+ options.firstFrameTimeoutMs,
4782
+ 'screencast.firstFrameTimeoutMs',
4783
+ 100,
4784
+ maxTimeoutMs,
4785
+ );
4786
+ }
4050
4787
  }
4051
4788
  }