@push.rocks/smartpuppeteer 2.4.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.
@@ -17,6 +17,7 @@ export interface ILiveBrowserScreencastOptions {
17
17
  maxHeight?: number;
18
18
  everyNthFrame?: number;
19
19
  maxOutstandingFrames?: number;
20
+ firstFrameTimeoutMs?: number;
20
21
  }
21
22
  export interface ILiveBrowserSecurityOptions {
22
23
  denyDownloads?: boolean;
@@ -117,6 +118,12 @@ export interface ILiveBrowserFrame {
117
118
  metadata: ILiveBrowserScreencastMetadata;
118
119
  data: Uint8Array;
119
120
  }
121
+ export interface ILiveBrowserFrameIdentity {
122
+ tabId: string;
123
+ sequence: number;
124
+ generation: number;
125
+ viewportRevision: number;
126
+ }
120
127
  export interface ILiveBrowserSnapshot {
121
128
  tabId: string;
122
129
  viewportRevision: number;
@@ -144,11 +151,7 @@ export type TLiveBrowserEventListener = (event: TLiveBrowserEvent) => void;
144
151
  export interface ILiveBrowserFrameAcknowledgement {
145
152
  accepted: boolean;
146
153
  }
147
- export interface ILiveBrowserFrameAcknowledgementRequest {
148
- tabId: string;
149
- sequence: number;
150
- generation: number;
151
- viewportRevision: number;
154
+ export interface ILiveBrowserFrameAcknowledgementRequest extends ILiveBrowserFrameIdentity {
152
155
  }
153
156
  export interface ILiveBrowserCreateTabOptions {
154
157
  url?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@push.rocks/smartpuppeteer",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "private": false,
5
5
  "description": "Provides simplified access to Puppeteer for automation and testing purposes.",
6
6
  "main": "dist_ts/index.js",
package/readme.hints.md CHANGED
@@ -4,6 +4,7 @@
4
4
  - `LiveBrowserSession` uses the browser's default context so all tabs and popups share one profile. Omitting both `launchOptions.userDataDir` and a `--user-data-dir` argument intentionally relies on Puppeteer's ephemeral profile lifecycle.
5
5
  - CDP is private to the live runtime. Public contracts contain transport-neutral values and `Uint8Array` image data, never `CDPSession`, raw CDP frame IDs, or base64 image strings.
6
6
  - Every published screencast frame has one private sequence-to-CDP acknowledgement entry. Public acknowledgement requires matching tab ID, sequence, generation, and viewport revision. `screencast.maxOutstandingFrames` bounds those entries independently of any application transport window; overflow retires oldest-first. Drops and all stream invalidation paths must issue each CDP acknowledgement at most once and await in-flight acknowledgements before detaching the CDP session.
7
+ - `refreshScreencast()` installs its exact-generation waiter before starting CDP, retires the previous stream, and resolves only after the first validated frame is published. Pre-aborted and queued calls reject without changing the stream. Active caller cancellation settles only after restorative restart; shutdown, tab replacement, crash, or closure revokes restart authority and must never resurrect the stream.
7
8
  - Activation, viewport changes, navigation, tab closure, snapshots, observations, semantic actions, and shutdown share one bounded operation scheduler. Raw input and frame acknowledgement remain direct, but must validate active tab, generation, and viewport revision. Repeated internal navigation/load state updates are coalesced per tab, and shutdown cancels queued work.
8
9
  - Retain the scheduler-owned launch `AbortController` for the full browser lifetime. Shutdown aborts both the active operation and Chromium itself so a non-signal-aware Puppeteer command or disabled protocol timeout cannot retain the browser ahead of queued cleanup.
9
10
  - Viewport revision starts at 1 and advances only after `Page.setViewport()` succeeds. Stop and flush the active screencast before applying a viewport or navigation mutation, then restart it with a new generation.
package/readme.md CHANGED
@@ -122,6 +122,7 @@ const session = new LiveBrowserSession({
122
122
  maxHeight: 720,
123
123
  everyNthFrame: 1,
124
124
  maxOutstandingFrames: 3,
125
+ firstFrameTimeoutMs: 5000,
125
126
  },
126
127
  launchOptions: {
127
128
  headless: true,
@@ -172,11 +173,11 @@ try {
172
173
 
173
174
  Only the active tab is streamed. Frames carry a session-monotonic sequence, tab/CDP generation, viewport revision, viewport, MIME type, encoded dimensions, screencast metadata, and binary `Uint8Array` data. Every delivered frame must be acknowledged with all four identity fields. A delivered frame remains pending until it is acknowledged, dropped, or retired by the runtime; mismatched, duplicate, stale, retired, or operationally failed acknowledgements return `{ accepted: false }`. Operational acknowledgement failures also emit an `error` event whose code is `frame_acknowledgement_failed`. `screencast.maxOutstandingFrames` is an integer from 1 through `liveBrowserMaxOutstandingFrames` (64) and defaults to `liveBrowserDefaultMaxOutstandingFrames` (3). At capacity, SmartPuppeteer retires and CDP-acknowledges the oldest pending frame before publishing the next one. Tab switches, navigation, resize, page cleanup, disconnect, and shutdown also retire pending frames and await their single CDP acknowledgement attempt before detaching the screencast session.
174
175
 
175
- The SmartPuppeteer outstanding-frame bound covers only the private frame-to-`Page.screencastFrameAck` lifecycle. It is not a binary transport window, and `acknowledgeFrame()` is not an application transport acknowledgement. A higher-level runtime must maintain and bound its application frame window separately. Screencast `format` accepts `jpeg` or `png`, `quality` accepts integers from 0 through 100, `maxWidth` and `maxHeight` accept integers from 1 through 4096 subject to an 8,294,400-pixel combined ceiling, and `everyNthFrame` accepts integers from 1 through 100.
176
+ The SmartPuppeteer outstanding-frame bound covers only the private frame-to-`Page.screencastFrameAck` lifecycle. It is not a binary transport window, and `acknowledgeFrame()` is not an application transport acknowledgement. A higher-level runtime must maintain and bound its application frame window separately. `refreshScreencast()` retires the active stream and resolves with an `ILiveBrowserFrameIdentity` for the exact first validated frame from a new generation. Its `tabId`, `sequence`, `generation`, and `viewportRevision` fields can be passed directly to `acknowledgeFrame()`. `screencast.firstFrameTimeoutMs` bounds both the CDP restart and first-frame arrival, accepts integers from 100 through 60,000, and defaults to 5,000; timeout or another refresh failure emits fatal `screencast_refresh_failed` and shuts the session down. Screencast `format` accepts `jpeg` or `png`, `quality` accepts integers from 0 through 100, `maxWidth` and `maxHeight` accept integers from 1 through 4096 subject to an 8,294,400-pixel combined ceiling, and `everyNthFrame` accepts integers from 1 through 100.
176
177
 
177
178
  The live API includes:
178
179
 
179
- - Lifecycle and state: `start()`, `stop()`, `terminate()`, `getProcessState()`, `onEvent()`, and `getState()`
180
+ - Lifecycle and state: `start()`, `stop()`, `terminate()`, `refreshScreencast()`, `getProcessState()`, `onEvent()`, and `getState()`
180
181
  - Tabs and navigation: `createTab()`, `activateTab()`, `closeTab()`, `navigate()`, `back()`, `forward()`, and `reload()`
181
182
  - Viewport and raw input: `setViewport()`, `dispatchMouse()`, `dispatchWheel()`, `dispatchKey()`, and `insertText()`
182
183
  - Agent-oriented actions: `click()`, `fill()`, and `press()` with bounded selectors and timeouts
@@ -185,7 +186,7 @@ The live API includes:
185
186
 
186
187
  Coordinate, keyboard, text, and semantic input messages include `tabId`, `generation`, and `viewportRevision`. This rejects input derived from an old stream generation, resize, or tab state. Snapshot, observation, and semantic operations are serialized with lifecycle mutations; inactive tabs receive the current session viewport before use. `viewport` takes precedence over `launchOptions.defaultViewport`; `null` falls back to 800x600. The runtime canonicalizes every page to a desktop, non-touch viewport because mobile emulation flags are outside the public viewport contract. Viewport dimensions, device scale factor, and physical pixel area are bounded, and full-page snapshots are intentionally unsupported. CDP sessions and CDP frame identifiers remain private implementation details. `LiveBrowserSession` owns launch cancellation, so callers cannot supply `launchOptions.signal`. It supports only Chromium over CDP and rejects Firefox or WebDriver BiDi launch selections.
187
188
 
188
- `start()`, tab and navigation methods, `setViewport()`, `captureSnapshot()`, `observe()`, semantic actions, and `evaluate()` accept a trailing `{ signal }` operation argument. A pre-aborted operation is never admitted. An operation aborted while queued is removed immediately. An active operation receives cancellation when its Puppeteer or CDP primitive supports it; otherwise its promise rejects only after the underlying work settles, and it continues to occupy the serialized queue until then. Cancellation therefore does not promise that an already-started browser side effect did not occur. `stop()`, frame acknowledgement, event/state access, and direct raw input are intentionally not caller-cancellable.
189
+ `start()`, `refreshScreencast()`, tab and navigation methods, `setViewport()`, `captureSnapshot()`, `observe()`, semantic actions, and `evaluate()` accept a trailing `{ signal }` operation argument. A pre-aborted operation is never admitted. An operation aborted while queued is removed immediately. An active operation receives cancellation when its Puppeteer or CDP primitive supports it; otherwise its promise rejects only after the underlying work settles, and it continues to occupy the serialized queue until then. An active `refreshScreencast()` completes the restorative restart before settling caller cancellation, unless session shutdown or page invalidation revokes that restart. Cancellation therefore does not promise that an already-started browser side effect did not occur. `stop()`, frame acknowledgement, event/state access, and direct raw input are intentionally not caller-cancellable.
189
190
 
190
191
  Optional browser guards can be enabled when composing a higher-level runtime:
191
192
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartpuppeteer',
6
- version: '2.4.0',
6
+ version: '2.5.0',
7
7
  description: 'Provides simplified access to Puppeteer for automation and testing purposes.'
8
8
  }
@@ -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;
@@ -123,6 +125,11 @@ interface IOwnedBrowserProcess {
123
125
  forceSignalled: boolean;
124
126
  }
125
127
 
128
+ interface IScreencastAuthority {
129
+ revision: number;
130
+ controller: AbortController;
131
+ }
132
+
126
133
  interface IPrivateLiveBrowserTab {
127
134
  id: string;
128
135
  page: plugins.puppeteer.Page;
@@ -133,6 +140,8 @@ interface IPrivateLiveBrowserTab {
133
140
  appliedViewportRevision: number;
134
141
  streaming: boolean;
135
142
  streamInvalidated: boolean;
143
+ streamLifecycleRevision: number;
144
+ screencastAuthority?: IScreencastAuthority;
136
145
  navigationInProgress: boolean;
137
146
  closing: boolean;
138
147
  stateUpdateQueued: boolean;
@@ -826,6 +835,77 @@ export class LiveBrowserSession {
826
835
  };
827
836
  }
828
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
+
829
909
  public async createTab(
830
910
  optionsArg: ILiveBrowserCreateTabOptions = {},
831
911
  operationOptions: ILiveBrowserOperationOptions = {},
@@ -1771,6 +1851,9 @@ export class LiveBrowserSession {
1771
1851
  this.emitState();
1772
1852
  }
1773
1853
  const shutdownError = new Error('LiveBrowserSession is stopping');
1854
+ for (const tab of this.tabs.values()) {
1855
+ this.invalidateScreencast(tab, shutdownError);
1856
+ }
1774
1857
  const activeOperation = this.activeOperation;
1775
1858
  const shouldAbortActiveOperation = Boolean(
1776
1859
  abortActiveOperation
@@ -2839,6 +2922,7 @@ export class LiveBrowserSession {
2839
2922
  appliedViewportRevision: 0,
2840
2923
  streaming: false,
2841
2924
  streamInvalidated: true,
2925
+ streamLifecycleRevision: 0,
2842
2926
  navigationInProgress: false,
2843
2927
  closing: false,
2844
2928
  stateUpdateQueued: false,
@@ -2919,7 +3003,7 @@ export class LiveBrowserSession {
2919
3003
  tab.evaluationExecutionContextId = undefined;
2920
3004
  const navigationReset = !tab.navigationInProgress;
2921
3005
  if (navigationReset) {
2922
- tab.streamInvalidated = true;
3006
+ this.invalidateScreencast(tab, new Error('Page navigation invalidated the screencast'));
2923
3007
  this.retireFramesForTabInBackground(tab.id);
2924
3008
  }
2925
3009
  this.requestTabStateUpdate(tab, navigationReset);
@@ -2928,7 +3012,7 @@ export class LiveBrowserSession {
2928
3012
  this.requestTabStateUpdate(tab, false);
2929
3013
  };
2930
3014
  const onClose = (): void => {
2931
- tab.streamInvalidated = true;
3015
+ this.invalidateScreencast(tab, new Error('Page closure invalidated the screencast'));
2932
3016
  this.retireFramesForTabInBackground(tab.id);
2933
3017
  if (tab.closing || this.normalStopRequested) {
2934
3018
  return;
@@ -2938,7 +3022,7 @@ export class LiveBrowserSession {
2938
3022
  }, 'page_close_cleanup_failed', tab.id, true);
2939
3023
  };
2940
3024
  const onCrash = (error: Error): void => {
2941
- tab.streamInvalidated = true;
3025
+ this.invalidateScreencast(tab, error);
2942
3026
  this.retireFramesForTabInBackground(tab.id);
2943
3027
  if (tab.closing || this.normalStopRequested) {
2944
3028
  return;
@@ -3301,7 +3385,10 @@ export class LiveBrowserSession {
3301
3385
  }
3302
3386
  }
3303
3387
 
3304
- private async startScreencast(tab: IPrivateLiveBrowserTab): Promise<void> {
3388
+ private async startScreencast(
3389
+ tab: IPrivateLiveBrowserTab,
3390
+ authorityArg?: IScreencastAuthority,
3391
+ ): Promise<void> {
3305
3392
  if (
3306
3393
  this.status !== 'running'
3307
3394
  || this.activeTabId !== tab.id
@@ -3312,80 +3399,254 @@ export class LiveBrowserSession {
3312
3399
  return;
3313
3400
  }
3314
3401
 
3315
- 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);
3316
3412
 
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'),
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,
3332
3459
  );
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
3460
 
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
- });
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);
3356
3473
  this.emitState();
3357
3474
  } 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.
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
+ }
3376
3503
  }
3377
3504
  }
3378
3505
  throw error;
3379
3506
  }
3380
3507
  }
3381
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
+
3382
3643
  private async stopScreencast(tab: IPrivateLiveBrowserTab): Promise<void> {
3383
3644
  const cdpSession = tab.cdpSession;
3384
3645
  const cdpConnection = tab.cdpConnection;
3385
3646
  const frameListener = tab.screencastFrameListener;
3386
3647
  const cdpSessionDetachedListener = tab.cdpSessionDetachedListener;
3387
3648
  tab.streaming = false;
3388
- tab.streamInvalidated = true;
3649
+ this.invalidateScreencast(tab, new Error('Screencast stopped'));
3389
3650
  tab.cdpSession = undefined;
3390
3651
  tab.cdpConnection = undefined;
3391
3652
  tab.screencastFrameListener = undefined;
@@ -3595,7 +3856,7 @@ export class LiveBrowserSession {
3595
3856
  ) {
3596
3857
  return;
3597
3858
  }
3598
- tab.streamInvalidated = true;
3859
+ this.invalidateScreencast(tab, error);
3599
3860
  this.retireFramesForTabInBackground(tab.id);
3600
3861
  this.scheduleOperation(async () => {
3601
3862
  if (!this.tabs.has(tab.id) || tab.cdpSession !== cdpSession) {
@@ -4047,5 +4308,13 @@ export class LiveBrowserSession {
4047
4308
  liveBrowserMaxOutstandingFrames,
4048
4309
  );
4049
4310
  }
4311
+ if (options.firstFrameTimeoutMs !== undefined) {
4312
+ validateInteger(
4313
+ options.firstFrameTimeoutMs,
4314
+ 'screencast.firstFrameTimeoutMs',
4315
+ 100,
4316
+ maxTimeoutMs,
4317
+ );
4318
+ }
4050
4319
  }
4051
4320
  }
@@ -25,6 +25,7 @@ export interface ILiveBrowserScreencastOptions {
25
25
  maxHeight?: number;
26
26
  everyNthFrame?: number;
27
27
  maxOutstandingFrames?: number;
28
+ firstFrameTimeoutMs?: number;
28
29
  }
29
30
 
30
31
  export interface ILiveBrowserSecurityOptions {
@@ -148,6 +149,13 @@ export interface ILiveBrowserFrame {
148
149
  data: Uint8Array;
149
150
  }
150
151
 
152
+ export interface ILiveBrowserFrameIdentity {
153
+ tabId: string;
154
+ sequence: number;
155
+ generation: number;
156
+ viewportRevision: number;
157
+ }
158
+
151
159
  export interface ILiveBrowserSnapshot {
152
160
  tabId: string;
153
161
  viewportRevision: number;
@@ -185,12 +193,7 @@ export interface ILiveBrowserFrameAcknowledgement {
185
193
  accepted: boolean;
186
194
  }
187
195
 
188
- export interface ILiveBrowserFrameAcknowledgementRequest {
189
- tabId: string;
190
- sequence: number;
191
- generation: number;
192
- viewportRevision: number;
193
- }
196
+ export interface ILiveBrowserFrameAcknowledgementRequest extends ILiveBrowserFrameIdentity {}
194
197
 
195
198
  export interface ILiveBrowserCreateTabOptions {
196
199
  url?: string;