@push.rocks/smartpuppeteer 2.5.0 → 2.7.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.
@@ -33,6 +33,8 @@ import type {
33
33
  ILiveBrowserOperationOptions,
34
34
  ILiveBrowserPressOptions,
35
35
  ILiveBrowserProcessState,
36
+ ILiveBrowserScreencastOptions,
37
+ ILiveBrowserScreencastUpdateOptions,
36
38
  ILiveBrowserSessionOptions,
37
39
  ILiveBrowserSnapshot,
38
40
  ILiveBrowserSnapshotOptions,
@@ -64,6 +66,7 @@ const maxSelectorLength = 4096;
64
66
  const maxTextLength = 32768;
65
67
  const maxUrlLength = 16384;
66
68
  const maxTimeoutMs = 60000;
69
+ const maxWheelDelta = 1000000;
67
70
  const frameAcknowledgementTimeoutMs = 5000;
68
71
  const defaultFirstFrameTimeoutMs = 5000;
69
72
  const maxQueuedPublicOperations = 64;
@@ -82,10 +85,12 @@ const maxProxySecuritySessions = 256;
82
85
  const maxTrackedProxyRequests = 1024;
83
86
  const maxTotalTrackedProxyRequests = 4096;
84
87
  const maxProxySecurityOperations = 2048;
88
+ const maxTrackedServiceWorkerVersions = 512;
89
+ const proxySecurityCommandTimeoutMs = 5000;
90
+ const proxySecurityCommandOptions = { timeout: proxySecurityCommandTimeoutMs } as const;
85
91
  const proxySeedTargetTypes = new Set([
86
92
  'background_page',
87
93
  'page',
88
- 'service_worker',
89
94
  'shared_worker',
90
95
  'webview',
91
96
  ]);
@@ -99,6 +104,9 @@ type TProxyAuthRequiredEvent = plugins.puppeteer.Protocol.Fetch.AuthRequiredEven
99
104
  type TProxyRequestPausedEvent = plugins.puppeteer.Protocol.Fetch.RequestPausedEvent;
100
105
  type TNetworkLoadingFinishedEvent = plugins.puppeteer.Protocol.Network.LoadingFinishedEvent;
101
106
  type TNetworkLoadingFailedEvent = plugins.puppeteer.Protocol.Network.LoadingFailedEvent;
107
+ type TServiceWorkerVersionUpdatedEvent = (
108
+ plugins.puppeteer.Protocol.ServiceWorker.WorkerVersionUpdatedEvent
109
+ );
102
110
 
103
111
  interface IProxySecuritySession {
104
112
  session: plugins.puppeteer.CDPSession;
@@ -108,6 +116,7 @@ interface IProxySecuritySession {
108
116
  fetchEnabled: boolean;
109
117
  allowLiveTargetDetach: boolean;
110
118
  ownedByProxySecurity: boolean;
119
+ retireWhenIdle: boolean;
111
120
  attemptedAuthentications: Set<string>;
112
121
  requestIdsByNetworkId: Map<string, string>;
113
122
  authRequiredListener: (event: TProxyAuthRequiredEvent) => void;
@@ -130,6 +139,34 @@ interface IScreencastAuthority {
130
139
  controller: AbortController;
131
140
  }
132
141
 
142
+ interface IPendingWheelDispatch {
143
+ cdpSession: plugins.puppeteer.CDPSession;
144
+ generation: number;
145
+ viewportRevision: number;
146
+ x: number;
147
+ y: number;
148
+ deltaX: number;
149
+ deltaY: number;
150
+ modifiers: number;
151
+ promise: Promise<void>;
152
+ resolve: () => void;
153
+ reject: (error: unknown) => void;
154
+ }
155
+
156
+ interface IQueuedWheelInput {
157
+ kind: 'wheel';
158
+ dispatch: IPendingWheelDispatch;
159
+ }
160
+
161
+ interface IQueuedEventInput {
162
+ kind: 'event';
163
+ send: () => Promise<void>;
164
+ resolve: () => void;
165
+ reject: (error: unknown) => void;
166
+ }
167
+
168
+ type TQueuedTabInput = IQueuedWheelInput | IQueuedEventInput;
169
+
133
170
  interface IPrivateLiveBrowserTab {
134
171
  id: string;
135
172
  page: plugins.puppeteer.Page;
@@ -146,13 +183,17 @@ interface IPrivateLiveBrowserTab {
146
183
  closing: boolean;
147
184
  stateUpdateQueued: boolean;
148
185
  stateUpdatePending: boolean;
149
- navigationResetPending: boolean;
186
+ navigationResetRevision: number;
187
+ restoredNavigationRevision: number;
150
188
  evaluationExecutionContextId?: number;
151
189
  securityCdpSession?: plugins.puppeteer.CDPSession;
152
190
  cdpSession?: plugins.puppeteer.CDPSession;
153
191
  cdpConnection?: plugins.puppeteer.Connection;
154
192
  screencastFrameListener?: TScreencastFrameListener;
155
193
  cdpSessionDetachedListener?: TCdpSessionDetachedListener;
194
+ inputQueue: TQueuedTabInput[];
195
+ inputPumpActive: boolean;
196
+ wheelDispatchInFlight?: Promise<void>;
156
197
  removeListeners: Array<() => void>;
157
198
  }
158
199
 
@@ -379,6 +420,80 @@ const createPuppeteerViewport = (
379
420
  hasTouch: false,
380
421
  });
381
422
 
423
+ const validateScreencastOptions = (
424
+ options: ILiveBrowserScreencastOptions | undefined,
425
+ ): void => {
426
+ if (!options) {
427
+ return;
428
+ }
429
+ if (options.format !== undefined && options.format !== 'jpeg' && options.format !== 'png') {
430
+ throw new Error('screencast.format must be jpeg or png');
431
+ }
432
+ if (options.quality !== undefined) {
433
+ validateInteger(options.quality, 'screencast.quality', 0, 100);
434
+ }
435
+ if (options.maxWidth !== undefined) {
436
+ validateInteger(options.maxWidth, 'screencast.maxWidth', 1, maxViewportWidth);
437
+ }
438
+ if (options.maxHeight !== undefined) {
439
+ validateInteger(options.maxHeight, 'screencast.maxHeight', 1, maxViewportHeight);
440
+ }
441
+ if (
442
+ options.maxWidth !== undefined
443
+ && options.maxHeight !== undefined
444
+ && options.maxWidth * options.maxHeight > maxViewportPixelArea
445
+ ) {
446
+ throw new Error(
447
+ `screencast pixel area must not exceed ${maxViewportPixelArea} pixels`,
448
+ );
449
+ }
450
+ if (options.everyNthFrame !== undefined) {
451
+ validateInteger(options.everyNthFrame, 'screencast.everyNthFrame', 1, 100);
452
+ }
453
+ if (options.maxOutstandingFrames !== undefined) {
454
+ validateInteger(
455
+ options.maxOutstandingFrames,
456
+ 'screencast.maxOutstandingFrames',
457
+ 1,
458
+ liveBrowserMaxOutstandingFrames,
459
+ );
460
+ }
461
+ if (options.firstFrameTimeoutMs !== undefined) {
462
+ validateInteger(
463
+ options.firstFrameTimeoutMs,
464
+ 'screencast.firstFrameTimeoutMs',
465
+ 100,
466
+ maxTimeoutMs,
467
+ );
468
+ }
469
+ };
470
+
471
+ const screencastUpdateKeys = ['quality', 'maxWidth', 'maxHeight', 'everyNthFrame'] as const;
472
+
473
+ const normalizeScreencastUpdate = (
474
+ optionsArg: ILiveBrowserScreencastUpdateOptions,
475
+ ): ILiveBrowserScreencastUpdateOptions => {
476
+ if (!optionsArg || typeof optionsArg !== 'object' || Array.isArray(optionsArg)) {
477
+ throw new Error('screencast update options must be an object');
478
+ }
479
+ const update: ILiveBrowserScreencastUpdateOptions = {};
480
+ for (const key of Object.keys(optionsArg)) {
481
+ if (!(screencastUpdateKeys as readonly string[]).includes(key)) {
482
+ throw new Error(`Unknown screencast update option: ${key}`);
483
+ }
484
+ const value = optionsArg[key as keyof ILiveBrowserScreencastUpdateOptions];
485
+ if (value !== undefined) {
486
+ update[key as keyof ILiveBrowserScreencastUpdateOptions] = value;
487
+ }
488
+ }
489
+ validateScreencastOptions(update);
490
+ return update;
491
+ };
492
+
493
+ const clampWheelDelta = (value: number): number => {
494
+ return Math.max(-maxWheelDelta, Math.min(maxWheelDelta, value));
495
+ };
496
+
382
497
  export class LiveBrowserSession {
383
498
  private readonly options: ILiveBrowserSessionOptions;
384
499
  private readonly eventListeners = new Set<TLiveBrowserEventListener>();
@@ -396,8 +511,18 @@ export class LiveBrowserSession {
396
511
  private browserSecurityCdpSession?: plugins.puppeteer.CDPSession;
397
512
  private browserSecurityConnection?: plugins.puppeteer.Connection;
398
513
  private browserSecuritySessionAttachedListener?: (session: plugins.puppeteer.CDPSession) => void;
514
+ private browserSecurityOwnedSessionAttachedListener?: (
515
+ session: plugins.puppeteer.CDPSession,
516
+ ) => void;
399
517
  private browserSecuritySessionDetachedListener?: TCdpSessionDetachedListener;
518
+ private browserSecurityWorkerLifecycleCdpSession?: plugins.puppeteer.CDPSession;
519
+ private browserSecurityWorkerVersionUpdatedListener?: (
520
+ event: TServiceWorkerVersionUpdatedEvent,
521
+ ) => void;
400
522
  private readonly proxySecuritySessions = new Map<string, IProxySecuritySession>();
523
+ private readonly ownedProxySecuritySessions = new Set<plugins.puppeteer.CDPSession>();
524
+ private readonly browserSecurityParentOwnedSessions = new Set<plugins.puppeteer.CDPSession>();
525
+ private readonly serviceWorkerTargetIdsByVersionId = new Map<string, string>();
401
526
  private readonly proxySecurityOperations = new Set<Promise<void>>();
402
527
  private proxySecurityGeneration = 0;
403
528
  private proxySecurityStopping = false;
@@ -507,7 +632,7 @@ export class LiveBrowserSession {
507
632
  : undefined,
508
633
  };
509
634
  this.viewport = { ...viewport };
510
- this.validateScreencastOptions();
635
+ validateScreencastOptions(this.options.screencast);
511
636
  this.maxOutstandingFrames = this.options.screencast?.maxOutstandingFrames
512
637
  ?? liveBrowserDefaultMaxOutstandingFrames;
513
638
  }
@@ -838,58 +963,95 @@ export class LiveBrowserSession {
838
963
  public refreshScreencast(
839
964
  operationOptions: ILiveBrowserOperationOptions = {},
840
965
  ): Promise<ILiveBrowserFrameIdentity> {
966
+ return this.enqueuePublicOperation(
967
+ (signal) => this.restartActiveScreencast(signal),
968
+ operationOptions,
969
+ );
970
+ }
971
+
972
+ public async updateScreencastOptions(
973
+ optionsArg: ILiveBrowserScreencastUpdateOptions,
974
+ operationOptions: ILiveBrowserOperationOptions = {},
975
+ ): Promise<ILiveBrowserFrameIdentity | null> {
976
+ const update = normalizeScreencastUpdate(optionsArg);
841
977
  return this.enqueuePublicOperation(async (signal) => {
842
978
  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}`);
979
+ const screencast: ILiveBrowserScreencastOptions = {
980
+ ...this.options.screencast,
981
+ ...update,
982
+ };
983
+ validateScreencastOptions(screencast);
984
+ this.options.screencast = screencast;
985
+ const activeTab = this.status === 'running' && this.activeTabId
986
+ ? this.tabs.get(this.activeTabId)
987
+ : undefined;
988
+ if (!activeTab?.streaming) {
989
+ return null;
846
990
  }
847
- let firstFrame: ReturnType<LiveBrowserSession['waitForScreencastFrame']> | undefined;
848
- let timeout: ReturnType<typeof setTimeout> | undefined;
849
- try {
850
- const previousLifecycleRevision = tab.streamLifecycleRevision;
991
+ return this.restartActiveScreencast(signal);
992
+ }, operationOptions);
993
+ }
994
+
995
+ private async restartActiveScreencast(
996
+ signal: AbortSignal,
997
+ ): Promise<ILiveBrowserFrameIdentity> {
998
+ signal.throwIfAborted();
999
+ const tab = this.requireActiveTab();
1000
+ let firstFrame: ReturnType<LiveBrowserSession['waitForScreencastFrame']> | undefined;
1001
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1002
+ let restartPromise: Promise<void> | undefined;
1003
+ try {
1004
+ let refreshLifecycleRevision = tab.streamLifecycleRevision;
1005
+ if (tab.streaming) {
851
1006
  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);
1007
+ refreshLifecycleRevision += 1;
1008
+ }
1009
+ if (
1010
+ !this.canRestoreScreencast(tab)
1011
+ || tab.streaming
1012
+ || tab.streamLifecycleRevision !== refreshLifecycleRevision
1013
+ ) {
1014
+ throw new Error(`Screencast lifecycle changed while refreshing tab: ${tab.id}`);
1015
+ }
1016
+ const authority = this.createScreencastAuthority(tab, refreshLifecycleRevision);
1017
+ const generation = tab.generation + 1;
1018
+ firstFrame = this.waitForScreencastFrame(
1019
+ tab,
1020
+ generation,
1021
+ this.viewportRevision,
1022
+ authority.controller.signal,
1023
+ );
1024
+ const refreshTimeoutMs = this.options.screencast?.firstFrameTimeoutMs
1025
+ ?? defaultFirstFrameTimeoutMs;
1026
+ const refreshTimeout = new Promise<never>((_resolve, reject) => {
1027
+ timeout = setTimeout(() => {
1028
+ reject(new Error(
1029
+ `Screencast generation ${generation} did not restart and produce a frame within ${
1030
+ refreshTimeoutMs
1031
+ }ms`,
1032
+ ));
1033
+ }, refreshTimeoutMs);
1034
+ });
1035
+ restartPromise = this.startScreencast(tab, authority);
1036
+ const [, identity] = await Promise.race([
1037
+ Promise.all([restartPromise, firstFrame.promise]),
1038
+ refreshTimeout,
1039
+ ]);
1040
+ return identity;
1041
+ } catch (error) {
1042
+ if (!this.canRestoreScreencast(tab)) throw error;
1043
+ await this.abandonScreencastRestart(tab, restartPromise, error);
1044
+ // Browser loss is the only refresh failure that is not tab-recoverable; the
1045
+ // disconnect listener normally revokes restoration first, so this stays a guard.
1046
+ const fatal = this.browser !== undefined && !this.browser.connected;
1047
+ const refreshError: ILiveBrowserError = {
1048
+ code: 'screencast_refresh_failed',
1049
+ message: normalizeErrorMessage(error),
1050
+ fatal,
1051
+ tabId: tab.id,
1052
+ };
1053
+ this.emitError(refreshError);
1054
+ if (fatal) {
893
1055
  void this.requestShutdown(refreshError).catch((shutdownError) => {
894
1056
  this.emitError({
895
1057
  code: 'screencast_refresh_shutdown_failed',
@@ -898,12 +1060,27 @@ export class LiveBrowserSession {
898
1060
  tabId: tab.id,
899
1061
  });
900
1062
  });
901
- throw error;
902
- } finally {
903
- if (timeout) clearTimeout(timeout);
904
- firstFrame?.cancel();
905
1063
  }
906
- }, operationOptions);
1064
+ throw error;
1065
+ } finally {
1066
+ if (timeout) clearTimeout(timeout);
1067
+ firstFrame?.cancel();
1068
+ }
1069
+ }
1070
+
1071
+ private async abandonScreencastRestart(
1072
+ tab: IPrivateLiveBrowserTab,
1073
+ restartPromise: Promise<void> | undefined,
1074
+ reason: unknown,
1075
+ ): Promise<void> {
1076
+ // Revoke the restart authority so an in-flight startScreencast() unwinds, then
1077
+ // retire anything it already established. The tab stays open and invalidated so
1078
+ // a later refresh, viewport change, or activation can start a new generation.
1079
+ this.invalidateScreencast(tab, reason);
1080
+ if (restartPromise) {
1081
+ await restartPromise.catch(() => {});
1082
+ }
1083
+ await this.stopScreencast(tab);
907
1084
  }
908
1085
 
909
1086
  public async createTab(
@@ -1133,43 +1310,73 @@ export class LiveBrowserSession {
1133
1310
  const clickCount = input.clickCount === undefined
1134
1311
  ? undefined
1135
1312
  : validateInteger(input.clickCount, 'clickCount', 0, 3);
1313
+ const modifiers = this.createModifierMask(input.modifiers);
1136
1314
 
1137
- try {
1138
- await cdpSession.send('Input.dispatchMouseEvent', {
1315
+ await this.dispatchTabInput(tab, cdpSession, () => cdpSession.send(
1316
+ 'Input.dispatchMouseEvent',
1317
+ {
1139
1318
  type,
1140
1319
  x: input.x,
1141
1320
  y: input.y,
1142
1321
  button,
1143
1322
  buttons,
1144
1323
  clickCount,
1145
- modifiers: this.createModifierMask(input.modifiers),
1324
+ modifiers,
1146
1325
  pointerType: 'mouse',
1147
- });
1148
- } catch (error) {
1149
- this.handlePossibleCdpDisconnection(tab, cdpSession, error);
1150
- throw error;
1151
- }
1326
+ },
1327
+ ));
1152
1328
  }
1153
1329
 
1154
1330
  public async dispatchWheel(input: ILiveBrowserWheelInput): Promise<void> {
1155
1331
  const { tab, cdpSession } = this.requireRawInputTarget(input);
1156
1332
  this.validateCoordinates(input.x, input.y);
1157
- validateFiniteNumber(input.deltaX, 'deltaX', -1000000, 1000000);
1158
- validateFiniteNumber(input.deltaY, 'deltaY', -1000000, 1000000);
1159
- try {
1160
- await cdpSession.send('Input.dispatchMouseEvent', {
1161
- type: 'mouseWheel',
1333
+ const deltaX = validateFiniteNumber(input.deltaX, 'deltaX', -maxWheelDelta, maxWheelDelta);
1334
+ const deltaY = validateFiniteNumber(input.deltaY, 'deltaY', -maxWheelDelta, maxWheelDelta);
1335
+ const modifiers = this.createModifierMask(input.modifiers);
1336
+
1337
+ // Queued input only waits while an earlier wheel dispatch is in flight; wheel
1338
+ // input for the same stream merges into a wheel entry still waiting at the tail.
1339
+ const lastQueuedInput = tab.inputQueue[tab.inputQueue.length - 1];
1340
+ if (
1341
+ lastQueuedInput?.kind === 'wheel'
1342
+ && lastQueuedInput.dispatch.cdpSession === cdpSession
1343
+ && lastQueuedInput.dispatch.generation === input.generation
1344
+ && lastQueuedInput.dispatch.viewportRevision === input.viewportRevision
1345
+ ) {
1346
+ const pendingDispatch = lastQueuedInput.dispatch;
1347
+ pendingDispatch.x = input.x;
1348
+ pendingDispatch.y = input.y;
1349
+ pendingDispatch.deltaX = clampWheelDelta(pendingDispatch.deltaX + deltaX);
1350
+ pendingDispatch.deltaY = clampWheelDelta(pendingDispatch.deltaY + deltaY);
1351
+ pendingDispatch.modifiers = modifiers;
1352
+ return pendingDispatch.promise;
1353
+ }
1354
+
1355
+ let resolveDispatch!: () => void;
1356
+ let rejectDispatch!: (error: unknown) => void;
1357
+ const promise = new Promise<void>((resolve, reject) => {
1358
+ resolveDispatch = resolve;
1359
+ rejectDispatch = reject;
1360
+ });
1361
+ void promise.catch(() => {});
1362
+ tab.inputQueue.push({
1363
+ kind: 'wheel',
1364
+ dispatch: {
1365
+ cdpSession,
1366
+ generation: input.generation,
1367
+ viewportRevision: input.viewportRevision,
1162
1368
  x: input.x,
1163
1369
  y: input.y,
1164
- deltaX: input.deltaX,
1165
- deltaY: input.deltaY,
1166
- modifiers: this.createModifierMask(input.modifiers),
1167
- pointerType: 'mouse',
1168
- });
1169
- } catch (error) {
1170
- this.handlePossibleCdpDisconnection(tab, cdpSession, error);
1171
- throw error;
1172
- }
1370
+ deltaX,
1371
+ deltaY,
1372
+ modifiers,
1373
+ promise,
1374
+ resolve: resolveDispatch,
1375
+ reject: rejectDispatch,
1376
+ },
1377
+ });
1378
+ this.pumpTabInput(tab);
1379
+ return promise;
1173
1380
  }
1174
1381
 
1175
1382
  public async dispatchKey(input: ILiveBrowserKeyInput): Promise<void> {
@@ -1203,9 +1410,11 @@ export class LiveBrowserSession {
1203
1410
  : validateInteger(input.location, 'location', 0, 3);
1204
1411
  validateOptionalBoolean(input.autoRepeat, 'autoRepeat');
1205
1412
  validateOptionalBoolean(input.isKeypad, 'isKeypad');
1413
+ const modifiers = this.createModifierMask(input.modifiers);
1206
1414
 
1207
- try {
1208
- await cdpSession.send('Input.dispatchKeyEvent', {
1415
+ await this.dispatchTabInput(tab, cdpSession, () => cdpSession.send(
1416
+ 'Input.dispatchKeyEvent',
1417
+ {
1209
1418
  type,
1210
1419
  key,
1211
1420
  code,
@@ -1216,22 +1425,97 @@ export class LiveBrowserSession {
1216
1425
  autoRepeat: input.autoRepeat,
1217
1426
  isKeypad: input.isKeypad,
1218
1427
  location,
1219
- modifiers: this.createModifierMask(input.modifiers),
1220
- });
1221
- } catch (error) {
1222
- this.handlePossibleCdpDisconnection(tab, cdpSession, error);
1223
- throw error;
1224
- }
1428
+ modifiers,
1429
+ },
1430
+ ));
1225
1431
  }
1226
1432
 
1227
1433
  public async insertText(input: ILiveBrowserInsertTextInput): Promise<void> {
1228
1434
  const { tab, cdpSession } = this.requireRawInputTarget(input);
1229
1435
  const text = validateBoundedString(input.text, 'text', 0, maxTextLength);
1436
+ await this.dispatchTabInput(tab, cdpSession, () => cdpSession.send(
1437
+ 'Input.insertText',
1438
+ { text },
1439
+ ));
1440
+ }
1441
+
1442
+ private dispatchTabInput(
1443
+ tab: IPrivateLiveBrowserTab,
1444
+ cdpSession: plugins.puppeteer.CDPSession,
1445
+ send: () => Promise<unknown>,
1446
+ ): Promise<void> {
1447
+ const guardedSend = async (): Promise<void> => {
1448
+ try {
1449
+ await send();
1450
+ } catch (error) {
1451
+ this.handlePossibleCdpDisconnection(tab, cdpSession, error);
1452
+ throw error;
1453
+ }
1454
+ };
1455
+ // Chromium does not keep a pipelined mouse or key event behind an earlier wheel
1456
+ // command, so any input behind wheel input waits until that wheel dispatch settles.
1457
+ if (tab.inputQueue.length === 0 && !tab.wheelDispatchInFlight) {
1458
+ return guardedSend();
1459
+ }
1460
+ return new Promise<void>((resolve, reject) => {
1461
+ tab.inputQueue.push({ kind: 'event', send: guardedSend, resolve, reject });
1462
+ this.pumpTabInput(tab);
1463
+ });
1464
+ }
1465
+
1466
+ private pumpTabInput(tab: IPrivateLiveBrowserTab): void {
1467
+ if (tab.inputPumpActive) {
1468
+ return;
1469
+ }
1470
+ tab.inputPumpActive = true;
1471
+ void (async () => {
1472
+ try {
1473
+ while (tab.inputQueue.length > 0) {
1474
+ const wheelDispatchInFlight = tab.wheelDispatchInFlight;
1475
+ if (wheelDispatchInFlight) {
1476
+ await wheelDispatchInFlight;
1477
+ if (tab.wheelDispatchInFlight === wheelDispatchInFlight) {
1478
+ tab.wheelDispatchInFlight = undefined;
1479
+ }
1480
+ continue;
1481
+ }
1482
+ const nextInput = tab.inputQueue.shift()!;
1483
+ if (nextInput.kind === 'wheel') {
1484
+ const inFlight = this.performWheelDispatch(tab, nextInput.dispatch);
1485
+ tab.wheelDispatchInFlight = inFlight;
1486
+ void inFlight.then(() => {
1487
+ if (tab.wheelDispatchInFlight === inFlight) {
1488
+ tab.wheelDispatchInFlight = undefined;
1489
+ }
1490
+ });
1491
+ } else {
1492
+ nextInput.send().then(nextInput.resolve, nextInput.reject);
1493
+ }
1494
+ }
1495
+ } finally {
1496
+ tab.inputPumpActive = false;
1497
+ }
1498
+ })();
1499
+ }
1500
+
1501
+ private async performWheelDispatch(
1502
+ tab: IPrivateLiveBrowserTab,
1503
+ dispatch: IPendingWheelDispatch,
1504
+ ): Promise<void> {
1230
1505
  try {
1231
- await cdpSession.send('Input.insertText', { text });
1506
+ await dispatch.cdpSession.send('Input.dispatchMouseEvent', {
1507
+ type: 'mouseWheel',
1508
+ x: dispatch.x,
1509
+ y: dispatch.y,
1510
+ deltaX: dispatch.deltaX,
1511
+ deltaY: dispatch.deltaY,
1512
+ modifiers: dispatch.modifiers,
1513
+ pointerType: 'mouse',
1514
+ });
1515
+ dispatch.resolve();
1232
1516
  } catch (error) {
1233
- this.handlePossibleCdpDisconnection(tab, cdpSession, error);
1234
- throw error;
1517
+ this.handlePossibleCdpDisconnection(tab, dispatch.cdpSession, error);
1518
+ dispatch.reject(error);
1235
1519
  }
1236
1520
  }
1237
1521
 
@@ -2276,7 +2560,10 @@ export class LiveBrowserSession {
2276
2560
  tab.securityCdpSession = undefined;
2277
2561
  if (securityCdpSession && !securityCdpSession.detached) {
2278
2562
  try {
2279
- await securityCdpSession.detach();
2563
+ await this.detachCdpSessionWithTimeout(
2564
+ securityCdpSession,
2565
+ 'page security session',
2566
+ );
2280
2567
  } catch {
2281
2568
  // Browser lifetime cancellation may close the target before explicit detach settles.
2282
2569
  }
@@ -2346,11 +2633,18 @@ export class LiveBrowserSession {
2346
2633
  if (!proxyCredentials) {
2347
2634
  try {
2348
2635
  if (denyPermissions) {
2349
- await cdpSession.send('Browser.grantPermissions', { permissions: [] });
2636
+ await cdpSession.send(
2637
+ 'Browser.grantPermissions',
2638
+ { permissions: [] },
2639
+ proxySecurityCommandOptions,
2640
+ );
2350
2641
  }
2351
2642
  } finally {
2352
2643
  if (!cdpSession.detached) {
2353
- await cdpSession.detach();
2644
+ await this.detachCdpSessionWithTimeout(
2645
+ cdpSession,
2646
+ 'browser permission session',
2647
+ );
2354
2648
  }
2355
2649
  }
2356
2650
  return;
@@ -2365,7 +2659,11 @@ export class LiveBrowserSession {
2365
2659
  this.browserSecurityCdpSession = cdpSession;
2366
2660
  try {
2367
2661
  if (denyPermissions) {
2368
- await cdpSession.send('Browser.grantPermissions', { permissions: [] });
2662
+ await cdpSession.send(
2663
+ 'Browser.grantPermissions',
2664
+ { permissions: [] },
2665
+ proxySecurityCommandOptions,
2666
+ );
2369
2667
  }
2370
2668
  const browserSecurityConnection = cdpSession.connection();
2371
2669
  if (!browserSecurityConnection) {
@@ -2373,13 +2671,69 @@ export class LiveBrowserSession {
2373
2671
  }
2374
2672
  this.browserSecurityConnection = browserSecurityConnection;
2375
2673
  this.browserSecuritySessionAttachedListener = (attachedSession) => {
2674
+ const setupPromise = this.configureProxySecuritySession(attachedSession, generation);
2675
+ queueMicrotask(() => {
2676
+ const securityRecord = this.proxySecuritySessions.get(attachedSession.id());
2677
+ if (
2678
+ securityRecord?.generation === generation
2679
+ && securityRecord.ownedByProxySecurity
2680
+ ) {
2681
+ return;
2682
+ }
2683
+ this.trackProxySecurityOperation(
2684
+ setupPromise,
2685
+ generation,
2686
+ 'proxy_security_session_setup_failed',
2687
+ );
2688
+ });
2689
+ };
2690
+ this.browserSecurityOwnedSessionAttachedListener = (attachedSession) => {
2691
+ const securityRecord = this.proxySecuritySessions.get(attachedSession.id());
2692
+ if (!securityRecord || securityRecord.generation !== generation) {
2693
+ return;
2694
+ }
2695
+ securityRecord.ownedByProxySecurity = true;
2696
+ this.ownedProxySecuritySessions.add(attachedSession);
2697
+ this.browserSecurityParentOwnedSessions.add(attachedSession);
2698
+ const setupPromise = securityRecord.setupPromise;
2699
+ securityRecord.setupPromise = setupPromise.then(async () => {
2700
+ try {
2701
+ await attachedSession.send(
2702
+ 'Runtime.runIfWaitingForDebugger',
2703
+ undefined,
2704
+ proxySecurityCommandOptions,
2705
+ );
2706
+ } catch (error) {
2707
+ this.removeProxySecuritySession(securityRecord);
2708
+ if (attachedSession.detached) {
2709
+ this.forgetOwnedProxySecuritySession(attachedSession);
2710
+ return;
2711
+ }
2712
+ if (this.proxySecurityStopping || this.normalStopRequested) {
2713
+ await this.detachOwnedProxySecuritySession(securityRecord);
2714
+ return;
2715
+ }
2716
+ if (
2717
+ securityRecord.targetId
2718
+ && await this.waitForProxySecurityTargetCoverage(
2719
+ securityRecord.targetId,
2720
+ generation,
2721
+ )
2722
+ ) {
2723
+ await this.detachOwnedProxySecuritySession(securityRecord);
2724
+ return;
2725
+ }
2726
+ throw error;
2727
+ }
2728
+ });
2376
2729
  this.trackProxySecurityOperation(
2377
- this.configureProxySecuritySession(attachedSession, generation),
2730
+ securityRecord.setupPromise,
2378
2731
  generation,
2379
2732
  'proxy_security_session_setup_failed',
2380
2733
  );
2381
2734
  };
2382
2735
  this.browserSecuritySessionDetachedListener = (detachedSession) => {
2736
+ this.forgetOwnedProxySecuritySession(detachedSession);
2383
2737
  if (detachedSession === cdpSession) {
2384
2738
  this.handleProxySecurityFailure(
2385
2739
  'proxy_security_browser_session_detached',
@@ -2388,6 +2742,14 @@ export class LiveBrowserSession {
2388
2742
  );
2389
2743
  return;
2390
2744
  }
2745
+ if (detachedSession === this.browserSecurityWorkerLifecycleCdpSession) {
2746
+ this.handleProxySecurityFailure(
2747
+ 'proxy_security_worker_lifecycle_detached',
2748
+ new Error('Proxy security service-worker lifecycle session detached unexpectedly'),
2749
+ generation,
2750
+ );
2751
+ return;
2752
+ }
2391
2753
  this.trackProxySecurityOperation(
2392
2754
  this.verifyProxySecuritySessionDetached(detachedSession, generation),
2393
2755
  generation,
@@ -2398,11 +2760,30 @@ export class LiveBrowserSession {
2398
2760
  'sessionattached',
2399
2761
  this.browserSecuritySessionAttachedListener,
2400
2762
  );
2763
+ cdpSession.on(
2764
+ plugins.puppeteer.CDPSessionEvent.SessionAttached,
2765
+ this.browserSecurityOwnedSessionAttachedListener,
2766
+ );
2401
2767
  browserSecurityConnection.on(
2402
2768
  'sessiondetached',
2403
2769
  this.browserSecuritySessionDetachedListener,
2404
2770
  );
2405
2771
 
2772
+ await cdpSession.send('Target.setAutoAttach', {
2773
+ autoAttach: true,
2774
+ waitForDebuggerOnStart: true,
2775
+ flatten: true,
2776
+ filter: [
2777
+ { type: 'service_worker' },
2778
+ { exclude: true },
2779
+ ],
2780
+ }, proxySecurityCommandOptions);
2781
+ await Promise.all(
2782
+ [...this.proxySecuritySessions.values()]
2783
+ .filter((record) => record.generation === generation && record.ownedByProxySecurity)
2784
+ .map((record) => record.setupPromise),
2785
+ );
2786
+
2406
2787
  for (const target of browser.targets()) {
2407
2788
  if (!proxySeedTargetTypes.has(target.type())) {
2408
2789
  continue;
@@ -2413,14 +2794,121 @@ export class LiveBrowserSession {
2413
2794
  throw new Error(`Proxy security did not observe session ${securitySession.id()}`);
2414
2795
  }
2415
2796
  securityRecord.ownedByProxySecurity = true;
2797
+ this.ownedProxySecuritySessions.add(securitySession);
2416
2798
  await securityRecord.setupPromise;
2417
2799
  }
2800
+ await this.configureProxySecurityWorkerLifecycle(cdpSession, generation);
2418
2801
  } catch (error) {
2419
2802
  await this.teardownProxySecurity();
2420
2803
  throw error;
2421
2804
  }
2422
2805
  }
2423
2806
 
2807
+ private async configureProxySecurityWorkerLifecycle(
2808
+ browserSecurityCdpSession: plugins.puppeteer.CDPSession,
2809
+ generation: number,
2810
+ ): Promise<void> {
2811
+ const lifecycleUrl = `data:text/html,<title>smartpuppeteer-proxy-security-${generation}</title>`;
2812
+ const { targetId } = await browserSecurityCdpSession.send('Target.createTarget', {
2813
+ url: lifecycleUrl,
2814
+ background: true,
2815
+ hidden: true,
2816
+ }, proxySecurityCommandOptions);
2817
+ const attachedSessions = new Map<string, plugins.puppeteer.CDPSession>();
2818
+ const captureAttachedSession = (session: plugins.puppeteer.CDPSession): void => {
2819
+ attachedSessions.set(session.id(), session);
2820
+ };
2821
+ browserSecurityCdpSession.on(
2822
+ plugins.puppeteer.CDPSessionEvent.SessionAttached,
2823
+ captureAttachedSession,
2824
+ );
2825
+ let lifecycleSession: plugins.puppeteer.CDPSession;
2826
+ try {
2827
+ const { sessionId } = await browserSecurityCdpSession.send('Target.attachToTarget', {
2828
+ targetId,
2829
+ flatten: true,
2830
+ }, proxySecurityCommandOptions);
2831
+ const attachedSession = attachedSessions.get(sessionId);
2832
+ if (!attachedSession) {
2833
+ throw new Error('Proxy security did not observe its hidden lifecycle session');
2834
+ }
2835
+ lifecycleSession = attachedSession;
2836
+ } finally {
2837
+ browserSecurityCdpSession.off(
2838
+ plugins.puppeteer.CDPSessionEvent.SessionAttached,
2839
+ captureAttachedSession,
2840
+ );
2841
+ }
2842
+
2843
+ const securityRecord = this.proxySecuritySessions.get(lifecycleSession.id());
2844
+ if (!securityRecord || !securityRecord.ownedByProxySecurity) {
2845
+ throw new Error('Proxy security did not configure its hidden lifecycle session');
2846
+ }
2847
+ await securityRecord.setupPromise;
2848
+ if (lifecycleSession.detached) {
2849
+ throw new Error('Proxy security hidden lifecycle session detached during setup');
2850
+ }
2851
+
2852
+ this.browserSecurityWorkerLifecycleCdpSession = lifecycleSession;
2853
+ this.browserSecurityWorkerVersionUpdatedListener = (event) => {
2854
+ const stoppedTargetIds = new Set<string>();
2855
+ const redundantTargetIds = new Set<string>();
2856
+ for (const version of event.versions) {
2857
+ if (version.targetId) {
2858
+ if (
2859
+ !this.serviceWorkerTargetIdsByVersionId.has(version.versionId)
2860
+ && this.serviceWorkerTargetIdsByVersionId.size >= maxTrackedServiceWorkerVersions
2861
+ ) {
2862
+ this.handleProxySecurityFailure(
2863
+ 'proxy_security_worker_tracking_capacity_exceeded',
2864
+ new Error(
2865
+ `Proxy security exceeded ${maxTrackedServiceWorkerVersions} service-worker versions`,
2866
+ ),
2867
+ generation,
2868
+ );
2869
+ return;
2870
+ }
2871
+ this.serviceWorkerTargetIdsByVersionId.set(version.versionId, version.targetId);
2872
+ }
2873
+ const knownTargetId = version.targetId
2874
+ ?? this.serviceWorkerTargetIdsByVersionId.get(version.versionId);
2875
+ if (version.status === 'redundant' && knownTargetId) {
2876
+ redundantTargetIds.add(knownTargetId);
2877
+ }
2878
+ if (version.runningStatus === 'stopped') {
2879
+ if (knownTargetId) {
2880
+ stoppedTargetIds.add(knownTargetId);
2881
+ }
2882
+ this.serviceWorkerTargetIdsByVersionId.delete(version.versionId);
2883
+ }
2884
+ }
2885
+ for (const workerSecurityRecord of [...this.proxySecuritySessions.values()]) {
2886
+ if (
2887
+ !workerSecurityRecord.ownedByProxySecurity
2888
+ || workerSecurityRecord.targetType !== 'service_worker'
2889
+ || !workerSecurityRecord.targetId
2890
+ || !this.browserSecurityParentOwnedSessions.has(workerSecurityRecord.session)
2891
+ ) {
2892
+ continue;
2893
+ }
2894
+ if (stoppedTargetIds.has(workerSecurityRecord.targetId)) {
2895
+ this.retireOwnedProxySecuritySession(workerSecurityRecord, true);
2896
+ } else if (redundantTargetIds.has(workerSecurityRecord.targetId)) {
2897
+ this.retireOwnedProxySecuritySession(workerSecurityRecord, false);
2898
+ }
2899
+ }
2900
+ };
2901
+ lifecycleSession.on(
2902
+ 'ServiceWorker.workerVersionUpdated',
2903
+ this.browserSecurityWorkerVersionUpdatedListener,
2904
+ );
2905
+ await lifecycleSession.send(
2906
+ 'ServiceWorker.enable',
2907
+ undefined,
2908
+ proxySecurityCommandOptions,
2909
+ );
2910
+ }
2911
+
2424
2912
  private configureProxySecuritySession(
2425
2913
  session: plugins.puppeteer.CDPSession,
2426
2914
  generation: number,
@@ -2483,7 +2971,7 @@ export class LiveBrowserSession {
2483
2971
  session.send('Fetch.continueWithAuth', {
2484
2972
  requestId: event.requestId,
2485
2973
  authChallengeResponse,
2486
- }).then(() => undefined),
2974
+ }, proxySecurityCommandOptions).then(() => undefined),
2487
2975
  generation,
2488
2976
  'proxy_security_protocol_failed',
2489
2977
  );
@@ -2516,7 +3004,11 @@ export class LiveBrowserSession {
2516
3004
  }
2517
3005
  }
2518
3006
  this.trackProxySecurityOperation(
2519
- session.send('Fetch.continueRequest', { requestId: event.requestId }).then(() => undefined),
3007
+ session.send(
3008
+ 'Fetch.continueRequest',
3009
+ { requestId: event.requestId },
3010
+ proxySecurityCommandOptions,
3011
+ ).then(() => undefined),
2520
3012
  generation,
2521
3013
  'proxy_security_protocol_failed',
2522
3014
  );
@@ -2528,6 +3020,13 @@ export class LiveBrowserSession {
2528
3020
  }
2529
3021
  requestIdsByNetworkId.delete(networkId);
2530
3022
  attemptedAuthentications.delete(requestId);
3023
+ if (
3024
+ proxySecuritySession.retireWhenIdle
3025
+ && attemptedAuthentications.size === 0
3026
+ && requestIdsByNetworkId.size === 0
3027
+ ) {
3028
+ this.retireOwnedProxySecuritySession(proxySecuritySession, false);
3029
+ }
2531
3030
  };
2532
3031
  const loadingFinishedListener = (event: TNetworkLoadingFinishedEvent): void => {
2533
3032
  forgetAuthentication(event.requestId);
@@ -2546,6 +3045,7 @@ export class LiveBrowserSession {
2546
3045
  fetchEnabled: false,
2547
3046
  allowLiveTargetDetach: false,
2548
3047
  ownedByProxySecurity: false,
3048
+ retireWhenIdle: false,
2549
3049
  attemptedAuthentications,
2550
3050
  requestIdsByNetworkId,
2551
3051
  authRequiredListener,
@@ -2554,12 +3054,20 @@ export class LiveBrowserSession {
2554
3054
  loadingFailedListener,
2555
3055
  setupPromise: Promise.resolve(),
2556
3056
  };
2557
- const targetInfoPromise = session.send('Target.getTargetInfo');
2558
- const networkEnablePromise = session.send('Network.enable');
3057
+ const targetInfoPromise = session.send(
3058
+ 'Target.getTargetInfo',
3059
+ undefined,
3060
+ proxySecurityCommandOptions,
3061
+ );
3062
+ const networkEnablePromise = session.send(
3063
+ 'Network.enable',
3064
+ undefined,
3065
+ proxySecurityCommandOptions,
3066
+ );
2559
3067
  const fetchEnablePromise = session.send('Fetch.enable', {
2560
3068
  handleAuthRequests: true,
2561
3069
  patterns: [{ urlPattern: '*' }],
2562
- });
3070
+ }, proxySecurityCommandOptions);
2563
3071
  const commandResultsPromise = Promise.allSettled([
2564
3072
  targetInfoPromise,
2565
3073
  networkEnablePromise,
@@ -2590,10 +3098,28 @@ export class LiveBrowserSession {
2590
3098
  proxySecuritySession.fetchEnabled = true;
2591
3099
  } catch (error) {
2592
3100
  this.removeProxySecuritySession(proxySecuritySession);
2593
- if (session.detached || this.proxySecurityStopping || this.normalStopRequested) {
3101
+ if (session.detached) {
3102
+ this.forgetOwnedProxySecuritySession(session);
2594
3103
  return;
2595
3104
  }
2596
- throw error;
3105
+ if (this.proxySecurityStopping || this.normalStopRequested) {
3106
+ await this.detachOwnedProxySecuritySession(proxySecuritySession);
3107
+ return;
3108
+ }
3109
+ if (
3110
+ proxySecuritySession.targetId
3111
+ && await this.waitForProxySecurityTargetCoverage(
3112
+ proxySecuritySession.targetId,
3113
+ generation,
3114
+ )
3115
+ ) {
3116
+ await this.detachOwnedProxySecuritySession(proxySecuritySession);
3117
+ return;
3118
+ }
3119
+ throw new Error(
3120
+ `Proxy security ${proxySecuritySession.ownedByProxySecurity ? 'owned' : 'observed'} session setup failed for ${proxySecuritySession.targetType ?? 'unknown'} target ${proxySecuritySession.targetId ?? 'unknown'}: ${normalizeErrorMessage(error)}`,
3121
+ { cause: error },
3122
+ );
2597
3123
  }
2598
3124
  })();
2599
3125
  proxySecuritySession.setupPromise = setupPromise;
@@ -2619,6 +3145,18 @@ export class LiveBrowserSession {
2619
3145
  if (!proxySecuritySession.targetId) {
2620
3146
  throw new Error(`Proxy security session detached before target identification: ${session.id()}`);
2621
3147
  }
3148
+ if (await this.waitForProxySecurityTargetCoverage(proxySecuritySession.targetId, generation)) {
3149
+ return;
3150
+ }
3151
+ throw new Error(
3152
+ `Proxy security detached from live target ${proxySecuritySession.targetId}`,
3153
+ );
3154
+ }
3155
+
3156
+ private async waitForProxySecurityTargetCoverage(
3157
+ targetId: string,
3158
+ generation: number,
3159
+ ): Promise<boolean> {
2622
3160
  for (let attempt = 0; attempt < 3; attempt += 1) {
2623
3161
  const generationSessions = [...this.proxySecuritySessions.values()].filter((candidate) => (
2624
3162
  candidate.generation === generation
@@ -2626,12 +3164,12 @@ export class LiveBrowserSession {
2626
3164
  await Promise.allSettled(generationSessions.map((candidate) => candidate.setupPromise));
2627
3165
  const hasReplacement = [...this.proxySecuritySessions.values()].some((candidate) => (
2628
3166
  candidate.generation === generation
2629
- && candidate.targetId === proxySecuritySession.targetId
3167
+ && candidate.targetId === targetId
2630
3168
  && candidate.fetchEnabled
2631
3169
  && !candidate.session.detached
2632
3170
  ));
2633
3171
  if (hasReplacement) {
2634
- return;
3172
+ return true;
2635
3173
  }
2636
3174
  if (attempt < 2) {
2637
3175
  await delay(25);
@@ -2641,12 +3179,12 @@ export class LiveBrowserSession {
2641
3179
  if (!browserSecurityCdpSession || browserSecurityCdpSession.detached) {
2642
3180
  throw new Error('Browser security CDP session detached unexpectedly');
2643
3181
  }
2644
- const { targetInfos } = await browserSecurityCdpSession.send('Target.getTargets');
2645
- if (targetInfos.some((targetInfo) => targetInfo.targetId === proxySecuritySession.targetId)) {
2646
- throw new Error(
2647
- `Proxy security detached from live target ${proxySecuritySession.targetId}`,
2648
- );
2649
- }
3182
+ const { targetInfos } = await browserSecurityCdpSession.send(
3183
+ 'Target.getTargets',
3184
+ undefined,
3185
+ proxySecurityCommandOptions,
3186
+ );
3187
+ return !targetInfos.some((targetInfo) => targetInfo.targetId === targetId);
2650
3188
  }
2651
3189
 
2652
3190
  private removeProxySecuritySession(proxySecuritySession: IProxySecuritySession): void {
@@ -2668,6 +3206,113 @@ export class LiveBrowserSession {
2668
3206
  this.proxySecuritySessions.delete(proxySecuritySession.session.id());
2669
3207
  }
2670
3208
 
3209
+ private async detachOwnedProxySecuritySession(
3210
+ proxySecuritySession: IProxySecuritySession,
3211
+ ): Promise<void> {
3212
+ if (!proxySecuritySession.ownedByProxySecurity) {
3213
+ return;
3214
+ }
3215
+ await this.detachTrackedOwnedProxySecuritySession(proxySecuritySession.session);
3216
+ }
3217
+
3218
+ private forgetOwnedProxySecuritySession(session: plugins.puppeteer.CDPSession): void {
3219
+ this.ownedProxySecuritySessions.delete(session);
3220
+ this.browserSecurityParentOwnedSessions.delete(session);
3221
+ }
3222
+
3223
+ private async detachCdpSessionWithTimeout(
3224
+ session: plugins.puppeteer.CDPSession,
3225
+ description: string,
3226
+ ): Promise<void> {
3227
+ let timeout: ReturnType<typeof setTimeout> | undefined;
3228
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
3229
+ timeout = setTimeout(() => {
3230
+ reject(new Error(
3231
+ `${description} did not detach within ${proxySecurityCommandTimeoutMs}ms`,
3232
+ ));
3233
+ }, proxySecurityCommandTimeoutMs);
3234
+ });
3235
+ try {
3236
+ await Promise.race([session.detach(), timeoutPromise]);
3237
+ } finally {
3238
+ if (timeout) {
3239
+ clearTimeout(timeout);
3240
+ }
3241
+ }
3242
+ }
3243
+
3244
+ private retireOwnedProxySecuritySession(
3245
+ proxySecuritySession: IProxySecuritySession,
3246
+ force: boolean,
3247
+ ): void {
3248
+ if (
3249
+ !proxySecuritySession.ownedByProxySecurity
3250
+ || this.proxySecuritySessions.get(proxySecuritySession.session.id())
3251
+ !== proxySecuritySession
3252
+ ) {
3253
+ return;
3254
+ }
3255
+ proxySecuritySession.retireWhenIdle = true;
3256
+ if (
3257
+ !force
3258
+ && (
3259
+ proxySecuritySession.attemptedAuthentications.size > 0
3260
+ || proxySecuritySession.requestIdsByNetworkId.size > 0
3261
+ )
3262
+ ) {
3263
+ return;
3264
+ }
3265
+ if (proxySecuritySession.targetId) {
3266
+ for (const [versionId, targetId] of this.serviceWorkerTargetIdsByVersionId) {
3267
+ if (targetId === proxySecuritySession.targetId) {
3268
+ this.serviceWorkerTargetIdsByVersionId.delete(versionId);
3269
+ }
3270
+ }
3271
+ }
3272
+ proxySecuritySession.allowLiveTargetDetach = true;
3273
+ this.removeProxySecuritySession(proxySecuritySession);
3274
+ this.trackProxySecurityOperation(
3275
+ this.detachOwnedProxySecuritySession(proxySecuritySession),
3276
+ proxySecuritySession.generation,
3277
+ 'proxy_security_worker_retirement_failed',
3278
+ );
3279
+ }
3280
+
3281
+ private async detachTrackedOwnedProxySecuritySession(
3282
+ session: plugins.puppeteer.CDPSession,
3283
+ ): Promise<void> {
3284
+ if (session.detached) {
3285
+ this.forgetOwnedProxySecuritySession(session);
3286
+ return;
3287
+ }
3288
+ try {
3289
+ if (this.browserSecurityParentOwnedSessions.has(session)) {
3290
+ const browserSecurityCdpSession = this.browserSecurityCdpSession;
3291
+ if (!browserSecurityCdpSession || browserSecurityCdpSession.detached) {
3292
+ throw new Error('Browser security parent session is unavailable');
3293
+ }
3294
+ await browserSecurityCdpSession.send('Target.detachFromTarget', {
3295
+ sessionId: session.id(),
3296
+ }, proxySecurityCommandOptions);
3297
+ } else {
3298
+ await this.detachCdpSessionWithTimeout(session, 'proxy security session');
3299
+ }
3300
+ this.forgetOwnedProxySecuritySession(session);
3301
+ } catch (error) {
3302
+ if (session.detached) {
3303
+ this.forgetOwnedProxySecuritySession(session);
3304
+ return;
3305
+ }
3306
+ if (this.proxySecurityStopping || this.normalStopRequested) {
3307
+ return;
3308
+ }
3309
+ throw new Error(
3310
+ `Failed to detach an owned proxy security session: ${normalizeErrorMessage(error)}`,
3311
+ { cause: error },
3312
+ );
3313
+ }
3314
+ }
3315
+
2671
3316
  private allowOperationalCdpSessionDetach(session: plugins.puppeteer.CDPSession): void {
2672
3317
  const proxySecuritySession = this.proxySecuritySessions.get(session.id());
2673
3318
  if (proxySecuritySession) {
@@ -2730,12 +3375,51 @@ export class LiveBrowserSession {
2730
3375
 
2731
3376
  private async teardownProxySecurity(): Promise<void> {
2732
3377
  this.proxySecurityStopping = true;
3378
+ this.serviceWorkerTargetIdsByVersionId.clear();
3379
+ const browserSecurityCdpSession = this.browserSecurityCdpSession;
3380
+ if (browserSecurityCdpSession && !browserSecurityCdpSession.detached) {
3381
+ try {
3382
+ await browserSecurityCdpSession.send('Target.setAutoAttach', {
3383
+ autoAttach: false,
3384
+ waitForDebuggerOnStart: false,
3385
+ flatten: true,
3386
+ }, proxySecurityCommandOptions);
3387
+ } catch {
3388
+ // Browser shutdown may detach the browser target before auto-attach is disabled.
3389
+ }
3390
+ }
3391
+ const workerLifecycleCdpSession = this.browserSecurityWorkerLifecycleCdpSession;
3392
+ if (workerLifecycleCdpSession && !workerLifecycleCdpSession.detached) {
3393
+ try {
3394
+ await workerLifecycleCdpSession.send(
3395
+ 'ServiceWorker.disable',
3396
+ undefined,
3397
+ proxySecurityCommandOptions,
3398
+ );
3399
+ } catch {
3400
+ // Browser shutdown can close the hidden lifecycle target first.
3401
+ }
3402
+ }
3403
+ if (workerLifecycleCdpSession && this.browserSecurityWorkerVersionUpdatedListener) {
3404
+ workerLifecycleCdpSession.off(
3405
+ 'ServiceWorker.workerVersionUpdated',
3406
+ this.browserSecurityWorkerVersionUpdatedListener,
3407
+ );
3408
+ }
3409
+ this.browserSecurityWorkerVersionUpdatedListener = undefined;
3410
+ this.browserSecurityWorkerLifecycleCdpSession = undefined;
2733
3411
  if (this.browserSecurityConnection && this.browserSecuritySessionAttachedListener) {
2734
3412
  this.browserSecurityConnection.off(
2735
3413
  'sessionattached',
2736
3414
  this.browserSecuritySessionAttachedListener,
2737
3415
  );
2738
3416
  }
3417
+ if (browserSecurityCdpSession && this.browserSecurityOwnedSessionAttachedListener) {
3418
+ browserSecurityCdpSession.off(
3419
+ plugins.puppeteer.CDPSessionEvent.SessionAttached,
3420
+ this.browserSecurityOwnedSessionAttachedListener,
3421
+ );
3422
+ }
2739
3423
  if (this.browserSecurityConnection && this.browserSecuritySessionDetachedListener) {
2740
3424
  this.browserSecurityConnection.off(
2741
3425
  'sessiondetached',
@@ -2743,28 +3427,31 @@ export class LiveBrowserSession {
2743
3427
  );
2744
3428
  }
2745
3429
  this.browserSecuritySessionAttachedListener = undefined;
3430
+ this.browserSecurityOwnedSessionAttachedListener = undefined;
2746
3431
  this.browserSecuritySessionDetachedListener = undefined;
2747
3432
  this.browserSecurityConnection = undefined;
2748
- const ownedSessions = [...this.proxySecuritySessions.values()]
2749
- .filter((record) => record.ownedByProxySecurity)
2750
- .map((record) => record.session);
3433
+ const ownedSessions = [...this.ownedProxySecuritySessions];
2751
3434
  for (const proxySecuritySession of [...this.proxySecuritySessions.values()]) {
2752
3435
  this.removeProxySecuritySession(proxySecuritySession);
2753
3436
  }
2754
3437
  for (const ownedSession of ownedSessions) {
2755
3438
  if (!ownedSession.detached) {
2756
3439
  try {
2757
- await ownedSession.detach();
3440
+ await this.detachTrackedOwnedProxySecuritySession(ownedSession);
2758
3441
  } catch {
2759
3442
  // Browser shutdown can close a target before explicit detach settles.
2760
3443
  }
2761
3444
  }
2762
3445
  }
2763
- const browserSecurityCdpSession = this.browserSecurityCdpSession;
3446
+ this.ownedProxySecuritySessions.clear();
3447
+ this.browserSecurityParentOwnedSessions.clear();
2764
3448
  this.browserSecurityCdpSession = undefined;
2765
3449
  if (browserSecurityCdpSession && !browserSecurityCdpSession.detached) {
2766
3450
  try {
2767
- await browserSecurityCdpSession.detach();
3451
+ await this.detachCdpSessionWithTimeout(
3452
+ browserSecurityCdpSession,
3453
+ 'browser security session',
3454
+ );
2768
3455
  } catch {
2769
3456
  // Browser shutdown can detach the browser target first.
2770
3457
  }
@@ -2927,7 +3614,10 @@ export class LiveBrowserSession {
2927
3614
  closing: false,
2928
3615
  stateUpdateQueued: false,
2929
3616
  stateUpdatePending: false,
2930
- navigationResetPending: false,
3617
+ navigationResetRevision: 0,
3618
+ restoredNavigationRevision: 0,
3619
+ inputQueue: [],
3620
+ inputPumpActive: false,
2931
3621
  removeListeners: [],
2932
3622
  };
2933
3623
  this.tabs.set(tab.id, tab);
@@ -2939,11 +3629,11 @@ export class LiveBrowserSession {
2939
3629
  tab.securityCdpSession = securityCdpSession;
2940
3630
  await securityCdpSession.send('Page.enable', {
2941
3631
  enableFileChooserOpenedEvent: true,
2942
- });
3632
+ }, proxySecurityCommandOptions);
2943
3633
  await securityCdpSession.send('Page.setInterceptFileChooserDialog', {
2944
3634
  enabled: true,
2945
3635
  cancel: true,
2946
- });
3636
+ }, proxySecurityCommandOptions);
2947
3637
  }
2948
3638
  await this.ensureTabViewport(tab);
2949
3639
  await this.refreshTab(tab);
@@ -3078,7 +3768,10 @@ export class LiveBrowserSession {
3078
3768
  } catch (error) {
3079
3769
  if (tab.securityCdpSession && !tab.securityCdpSession.detached) {
3080
3770
  try {
3081
- await tab.securityCdpSession.detach();
3771
+ await this.detachCdpSessionWithTimeout(
3772
+ tab.securityCdpSession,
3773
+ 'page security session',
3774
+ );
3082
3775
  } catch {
3083
3776
  // The page may have closed while security setup was failing.
3084
3777
  }
@@ -3117,31 +3810,58 @@ export class LiveBrowserSession {
3117
3810
  return;
3118
3811
  }
3119
3812
  tab.stateUpdatePending = true;
3120
- tab.navigationResetPending ||= navigationReset;
3813
+ if (navigationReset) {
3814
+ tab.navigationResetRevision += 1;
3815
+ }
3121
3816
  if (tab.stateUpdateQueued) {
3122
3817
  return;
3123
3818
  }
3124
3819
  tab.stateUpdateQueued = true;
3125
3820
  const wasScheduled = this.scheduleOperation(async () => {
3126
3821
  try {
3127
- const shouldResetNavigation = tab.navigationResetPending;
3822
+ const navigationResetRevision = tab.navigationResetRevision;
3823
+ const shouldResetNavigation = (
3824
+ tab.restoredNavigationRevision < navigationResetRevision
3825
+ );
3128
3826
  tab.stateUpdatePending = false;
3129
- tab.navigationResetPending = false;
3130
3827
  if (!this.tabs.has(tab.id) || tab.closing) {
3131
3828
  return;
3132
3829
  }
3133
3830
  if (shouldResetNavigation && this.activeTabId === tab.id) {
3134
3831
  await this.stopScreencast(tab);
3135
3832
  }
3136
- await this.refreshTab(tab);
3137
- this.emitState();
3833
+ let stateUpdateError: unknown;
3834
+ try {
3835
+ await this.refreshTab(tab);
3836
+ this.emitState();
3837
+ } catch (error) {
3838
+ stateUpdateError = error;
3839
+ }
3138
3840
  if (
3139
3841
  shouldResetNavigation
3140
3842
  && this.activeTabId === tab.id
3141
3843
  && this.status === 'running'
3142
3844
  && tab.status === 'open'
3143
3845
  ) {
3144
- await this.startScreencast(tab);
3846
+ try {
3847
+ await this.startScreencast(tab);
3848
+ } catch (error) {
3849
+ throw stateUpdateError
3850
+ ? new AggregateError(
3851
+ [stateUpdateError, error],
3852
+ 'Page state refresh and screencast restart failed',
3853
+ )
3854
+ : error;
3855
+ }
3856
+ }
3857
+ if (shouldResetNavigation) {
3858
+ tab.restoredNavigationRevision = Math.max(
3859
+ tab.restoredNavigationRevision,
3860
+ navigationResetRevision,
3861
+ );
3862
+ }
3863
+ if (stateUpdateError) {
3864
+ throw stateUpdateError;
3145
3865
  }
3146
3866
  } finally {
3147
3867
  tab.stateUpdateQueued = false;
@@ -3346,15 +4066,32 @@ export class LiveBrowserSession {
3346
4066
  } finally {
3347
4067
  tab.navigationInProgress = false;
3348
4068
  if (this.tabs.has(tab.id) && !tab.page.isClosed()) {
3349
- await this.refreshTab(tab);
3350
- this.emitState();
4069
+ let stateUpdateError: unknown;
4070
+ try {
4071
+ await this.refreshTab(tab);
4072
+ this.emitState();
4073
+ } catch (error) {
4074
+ stateUpdateError = error;
4075
+ }
3351
4076
  if (
3352
4077
  isActive
3353
4078
  && this.activeTabId === tab.id
3354
4079
  && this.status === 'running'
3355
4080
  && tab.status === 'open'
3356
4081
  ) {
3357
- await this.startScreencast(tab);
4082
+ try {
4083
+ await this.startScreencast(tab);
4084
+ } catch (error) {
4085
+ throw stateUpdateError
4086
+ ? new AggregateError(
4087
+ [stateUpdateError, error],
4088
+ 'Navigation state refresh and screencast restart failed',
4089
+ )
4090
+ : error;
4091
+ }
4092
+ }
4093
+ if (stateUpdateError) {
4094
+ throw stateUpdateError;
3358
4095
  }
3359
4096
  }
3360
4097
  }
@@ -4271,50 +5008,4 @@ export class LiveBrowserSession {
4271
5008
  return validatedWaitUntil;
4272
5009
  }
4273
5010
 
4274
- private validateScreencastOptions(): void {
4275
- const options = this.options.screencast;
4276
- if (!options) {
4277
- return;
4278
- }
4279
- if (options.format !== undefined && options.format !== 'jpeg' && options.format !== 'png') {
4280
- throw new Error('screencast.format must be jpeg or png');
4281
- }
4282
- if (options.quality !== undefined) {
4283
- validateInteger(options.quality, 'screencast.quality', 0, 100);
4284
- }
4285
- if (options.maxWidth !== undefined) {
4286
- validateInteger(options.maxWidth, 'screencast.maxWidth', 1, maxViewportWidth);
4287
- }
4288
- if (options.maxHeight !== undefined) {
4289
- validateInteger(options.maxHeight, 'screencast.maxHeight', 1, maxViewportHeight);
4290
- }
4291
- if (
4292
- options.maxWidth !== undefined
4293
- && options.maxHeight !== undefined
4294
- && options.maxWidth * options.maxHeight > maxViewportPixelArea
4295
- ) {
4296
- throw new Error(
4297
- `screencast pixel area must not exceed ${maxViewportPixelArea} pixels`,
4298
- );
4299
- }
4300
- if (options.everyNthFrame !== undefined) {
4301
- validateInteger(options.everyNthFrame, 'screencast.everyNthFrame', 1, 100);
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
- }
4319
- }
4320
5011
  }