@push.rocks/smartpuppeteer 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,18 @@
1
1
  import { getEnvAwareBrowserInstance } from './smartpuppeteer.classes.smartpuppeteer.js';
2
+ import {
3
+ delay,
4
+ type IOwnedProcessIdentity,
5
+ killFrozenOwnedProcessGroup,
6
+ listOwnedProcessGroupMembers,
7
+ readOwnedProcessIdentity,
8
+ signalOwnedProcessIdentity,
9
+ signalOwnedProcessGroup,
10
+ } from './smartpuppeteer.helpers.process.js';
2
11
  import type {
3
12
  ILiveBrowserClickOptions,
4
13
  ILiveBrowserCreateTabOptions,
5
14
  ILiveBrowserError,
15
+ ILiveBrowserEvaluateOptions,
6
16
  ILiveBrowserFillOptions,
7
17
  ILiveBrowserFrame,
8
18
  ILiveBrowserFrameAcknowledgement,
@@ -15,17 +25,22 @@ import type {
15
25
  ILiveBrowserNavigationOptions,
16
26
  ILiveBrowserObservation,
17
27
  ILiveBrowserObserveOptions,
28
+ ILiveBrowserOperationOptions,
18
29
  ILiveBrowserPressOptions,
30
+ ILiveBrowserProcessState,
19
31
  ILiveBrowserSessionOptions,
20
32
  ILiveBrowserSnapshot,
21
33
  ILiveBrowserSnapshotOptions,
22
34
  ILiveBrowserState,
23
35
  ILiveBrowserTabState,
36
+ ILiveBrowserTerminationOptions,
37
+ ILiveBrowserTerminationResult,
24
38
  ILiveBrowserViewport,
25
39
  ILiveBrowserWheelInput,
26
40
  TLiveBrowserEvent,
27
41
  TLiveBrowserEventListener,
28
42
  TLiveBrowserImageFormat,
43
+ TLiveBrowserJsonValue,
29
44
  TLiveBrowserWaitUntil,
30
45
  } from './smartpuppeteer.interfaces.livebrowser.js';
31
46
  import * as plugins from './smartpuppeteer.plugins.js';
@@ -47,10 +62,62 @@ const maxTimeoutMs = 60000;
47
62
  const maxOutstandingFrames = 3;
48
63
  const maxQueuedPublicOperations = 64;
49
64
  const maxQueuedInternalOperations = 128;
65
+ const maxEvaluationScriptBytes = 262144;
66
+ const maxEvaluationOutputBytes = 1048576;
67
+ const maxEvaluationDepth = 32;
68
+ const maxEvaluationNodes = 50000;
69
+ const maxEvaluationStringBytes = 262144;
70
+ const maxEvaluationArrayLength = 10000;
71
+ const maxEvaluationObjectKeys = 10000;
72
+ const evaluationBootstrapKey = '__smartpuppeteerEvaluate';
73
+ const evaluationCancelKey = '__smartpuppeteerCancel';
74
+ const evaluationCleanupKey = '__smartpuppeteerCleanup';
75
+ const maxProxySecuritySessions = 256;
76
+ const maxTrackedProxyRequests = 1024;
77
+ const maxTotalTrackedProxyRequests = 4096;
78
+ const maxProxySecurityOperations = 2048;
79
+ const proxySeedTargetTypes = new Set([
80
+ 'background_page',
81
+ 'page',
82
+ 'service_worker',
83
+ 'shared_worker',
84
+ 'webview',
85
+ ]);
86
+ const proxyFetchUnsupportedTargetTypes = new Set(['tab', 'worker']);
50
87
 
51
88
  type TScreencastFrameEvent = plugins.puppeteer.Protocol.Page.ScreencastFrameEvent;
52
89
  type TScreencastFrameListener = (event: TScreencastFrameEvent) => void;
53
90
  type TCdpSessionDetachedListener = (session: plugins.puppeteer.CDPSession) => void;
91
+ type TOwnedChildProcess = NonNullable<ReturnType<plugins.puppeteer.Browser['process']>>;
92
+ type TProxyAuthRequiredEvent = plugins.puppeteer.Protocol.Fetch.AuthRequiredEvent;
93
+ type TProxyRequestPausedEvent = plugins.puppeteer.Protocol.Fetch.RequestPausedEvent;
94
+ type TNetworkLoadingFinishedEvent = plugins.puppeteer.Protocol.Network.LoadingFinishedEvent;
95
+ type TNetworkLoadingFailedEvent = plugins.puppeteer.Protocol.Network.LoadingFailedEvent;
96
+
97
+ interface IProxySecuritySession {
98
+ session: plugins.puppeteer.CDPSession;
99
+ generation: number;
100
+ targetId?: string;
101
+ targetType?: string;
102
+ fetchEnabled: boolean;
103
+ allowLiveTargetDetach: boolean;
104
+ ownedByProxySecurity: boolean;
105
+ attemptedAuthentications: Set<string>;
106
+ requestIdsByNetworkId: Map<string, string>;
107
+ authRequiredListener: (event: TProxyAuthRequiredEvent) => void;
108
+ requestPausedListener: (event: TProxyRequestPausedEvent) => void;
109
+ loadingFinishedListener: (event: TNetworkLoadingFinishedEvent) => void;
110
+ loadingFailedListener: (event: TNetworkLoadingFailedEvent) => void;
111
+ setupPromise: Promise<void>;
112
+ }
113
+
114
+ interface IOwnedBrowserProcess {
115
+ generation: number;
116
+ childProcess: TOwnedChildProcess;
117
+ identity?: IOwnedProcessIdentity;
118
+ exitPromise: Promise<void>;
119
+ forceSignalled: boolean;
120
+ }
54
121
 
55
122
  interface IPrivateLiveBrowserTab {
56
123
  id: string;
@@ -67,6 +134,8 @@ interface IPrivateLiveBrowserTab {
67
134
  stateUpdateQueued: boolean;
68
135
  stateUpdatePending: boolean;
69
136
  navigationResetPending: boolean;
137
+ evaluationExecutionContextId?: number;
138
+ securityCdpSession?: plugins.puppeteer.CDPSession;
70
139
  cdpSession?: plugins.puppeteer.CDPSession;
71
140
  cdpConnection?: plugins.puppeteer.Connection;
72
141
  screencastFrameListener?: TScreencastFrameListener;
@@ -87,14 +156,34 @@ interface IImageDimensions {
87
156
  height: number;
88
157
  }
89
158
 
159
+ interface INormalizedEvaluationOptions {
160
+ timeoutMs: number;
161
+ maxOutputBytes: number;
162
+ maxDepth: number;
163
+ maxNodes: number;
164
+ maxStringBytes: number;
165
+ maxArrayLength: number;
166
+ maxObjectKeys: number;
167
+ }
168
+
169
+ interface IEvaluationEnvelope {
170
+ ok: boolean;
171
+ json?: string;
172
+ error?: string;
173
+ }
174
+
90
175
  type TQueuedOperationKind = 'public' | 'internal' | 'shutdown';
176
+ type TQueuedOperationState = 'queued' | 'active' | 'settled';
91
177
 
92
178
  interface IQueuedOperation {
93
179
  kind: TQueuedOperationKind;
180
+ state: TQueuedOperationState;
94
181
  controller: AbortController;
95
182
  run: (signal: AbortSignal) => Promise<unknown>;
96
183
  resolve: (value: unknown) => void;
97
184
  reject: (error: unknown) => void;
185
+ capacityReleased: boolean;
186
+ removeCallerAbortListener?: () => void;
98
187
  }
99
188
 
100
189
  const validateBoundedString = (
@@ -163,6 +252,10 @@ const normalizeErrorMessage = (error: unknown): string => {
163
252
  return truncate(String(error), 2048);
164
253
  };
165
254
 
255
+ const normalizeAbortReason = (signal: AbortSignal): unknown => {
256
+ return signal.reason ?? new Error('The browser operation was aborted');
257
+ };
258
+
166
259
  const readUint32 = (data: Uint8Array, offset: number): number => {
167
260
  return (
168
261
  data[offset]! * 0x1000000
@@ -279,6 +372,20 @@ export class LiveBrowserSession {
279
372
  private browserContext?: plugins.puppeteer.BrowserContext;
280
373
  private browserLifetimeController?: AbortController;
281
374
  private browserDisconnectedListener?: () => void;
375
+ private browserTargetCreatedListener?: (target: plugins.puppeteer.Target) => void;
376
+ private browserSecurityCdpSession?: plugins.puppeteer.CDPSession;
377
+ private browserSecurityConnection?: plugins.puppeteer.Connection;
378
+ private browserSecuritySessionAttachedListener?: (session: plugins.puppeteer.CDPSession) => void;
379
+ private browserSecuritySessionDetachedListener?: TCdpSessionDetachedListener;
380
+ private readonly proxySecuritySessions = new Map<string, IProxySecuritySession>();
381
+ private readonly proxySecurityOperations = new Set<Promise<void>>();
382
+ private proxySecurityGeneration = 0;
383
+ private proxySecurityStopping = false;
384
+ private ownedBrowserProcess?: IOwnedBrowserProcess;
385
+ private processGeneration = 0;
386
+ private startRequestCount = 0;
387
+ private terminationSettled = true;
388
+ private terminationPromise?: Promise<ILiveBrowserTerminationResult>;
282
389
  private readonly operationQueue: IQueuedOperation[] = [];
283
390
  private activeOperation?: IQueuedOperation;
284
391
  private operationRunning = false;
@@ -291,6 +398,7 @@ export class LiveBrowserSession {
291
398
  private viewportRevision = 1;
292
399
  private tabSequence = 0;
293
400
  private frameSequence = 0;
401
+ private evaluationSequence = 0;
294
402
  private normalStopRequested = false;
295
403
  private lastError?: ILiveBrowserError;
296
404
 
@@ -310,6 +418,33 @@ export class LiveBrowserSession {
310
418
  if (optionsArg.launchOptions && 'signal' in optionsArg.launchOptions) {
311
419
  throw new Error('LiveBrowserSession owns launch cancellation; launchOptions.signal is unsupported');
312
420
  }
421
+ validateOptionalBoolean(optionsArg.allowEvaluation, 'allowEvaluation');
422
+ for (const name of [
423
+ 'denyDownloads',
424
+ 'denyFileChoosers',
425
+ 'denyPermissions',
426
+ 'httpNavigationOnly',
427
+ ] as const) {
428
+ validateOptionalBoolean(optionsArg.security?.[name], `security.${name}`);
429
+ }
430
+ const proxyCredentials = optionsArg.security?.proxyCredentials;
431
+ if (proxyCredentials !== undefined) {
432
+ if (!proxyCredentials || typeof proxyCredentials !== 'object') {
433
+ throw new Error('security.proxyCredentials must be an object');
434
+ }
435
+ validateBoundedString(
436
+ proxyCredentials.username,
437
+ 'security.proxyCredentials.username',
438
+ 1,
439
+ 256,
440
+ );
441
+ validateBoundedString(
442
+ proxyCredentials.password,
443
+ 'security.proxyCredentials.password',
444
+ 1,
445
+ 1024,
446
+ );
447
+ }
313
448
  const launchViewport = optionsArg.launchOptions?.defaultViewport;
314
449
  const viewport = normalizeViewport(
315
450
  optionsArg.viewport
@@ -327,9 +462,18 @@ export class LiveBrowserSession {
327
462
  ...optionsArg.launchOptions,
328
463
  args: [...(optionsArg.launchOptions?.args ?? [])],
329
464
  defaultViewport: createPuppeteerViewport(viewport),
465
+ ...(optionsArg.security?.denyDownloads
466
+ ? { downloadBehavior: { policy: 'deny' as const } }
467
+ : {}),
330
468
  },
331
469
  viewport,
332
470
  screencast: optionsArg.screencast ? { ...optionsArg.screencast } : undefined,
471
+ security: optionsArg.security
472
+ ? {
473
+ ...optionsArg.security,
474
+ proxyCredentials: proxyCredentials ? { ...proxyCredentials } : undefined,
475
+ }
476
+ : undefined,
333
477
  };
334
478
  this.viewport = { ...viewport };
335
479
  this.validateScreencastOptions();
@@ -356,12 +500,86 @@ export class LiveBrowserSession {
356
500
  };
357
501
  }
358
502
 
359
- public async start(): Promise<void> {
360
- return this.enqueuePublicOperation(async (signal) => {
503
+ public getProcessState(): ILiveBrowserProcessState {
504
+ const ownedProcess = this.ownedBrowserProcess;
505
+ if (!ownedProcess) {
506
+ return {
507
+ generation: this.processGeneration,
508
+ pid: null,
509
+ processGroupId: null,
510
+ running: false,
511
+ exitCode: null,
512
+ signalCode: null,
513
+ };
514
+ }
515
+ const childProcess = ownedProcess.childProcess;
516
+ return {
517
+ generation: ownedProcess.generation,
518
+ pid: childProcess.pid ?? null,
519
+ processGroupId: ownedProcess.identity?.processGroupId ?? null,
520
+ running: childProcess.exitCode === null && childProcess.signalCode === null,
521
+ exitCode: childProcess.exitCode,
522
+ signalCode: childProcess.signalCode,
523
+ };
524
+ }
525
+
526
+ public terminate(
527
+ optionsArg: ILiveBrowserTerminationOptions = {},
528
+ ): Promise<ILiveBrowserTerminationResult> {
529
+ if (this.terminationPromise) {
530
+ return this.terminationPromise;
531
+ }
532
+ const gracefulTimeoutMs = optionsArg.gracefulTimeoutMs === undefined
533
+ ? 5000
534
+ : validateInteger(optionsArg.gracefulTimeoutMs, 'gracefulTimeoutMs', 1, 60000);
535
+ const forceTimeoutMs = optionsArg.forceTimeoutMs === undefined
536
+ ? 5000
537
+ : validateInteger(optionsArg.forceTimeoutMs, 'forceTimeoutMs', 1, 60000);
538
+ let resolveTermination!: (result: ILiveBrowserTerminationResult) => void;
539
+ let rejectTermination!: (error: unknown) => void;
540
+ const terminationPromise = new Promise<ILiveBrowserTerminationResult>((resolve, reject) => {
541
+ resolveTermination = resolve;
542
+ rejectTermination = reject;
543
+ });
544
+ this.terminationPromise = terminationPromise;
545
+ this.terminationSettled = false;
546
+ void this.terminateInternal(gracefulTimeoutMs, forceTimeoutMs).then(
547
+ (result) => {
548
+ this.terminationSettled = true;
549
+ resolveTermination(result);
550
+ },
551
+ (error) => {
552
+ if (this.terminationPromise === terminationPromise) {
553
+ this.terminationPromise = undefined;
554
+ }
555
+ this.terminationSettled = true;
556
+ rejectTermination(error);
557
+ },
558
+ );
559
+ return terminationPromise;
560
+ }
561
+
562
+ public start(operationOptions: ILiveBrowserOperationOptions = {}): Promise<void> {
563
+ if (!this.terminationSettled) {
564
+ return Promise.reject(new Error('A browser termination is still in progress'));
565
+ }
566
+ if (!this.ownedBrowserProcess && this.status === 'stopped') {
567
+ this.terminationPromise = undefined;
568
+ }
569
+ this.startRequestCount += 1;
570
+ const startPromise = this.enqueuePublicOperation(async (signal) => {
361
571
  if (this.status === 'running' || this.status === 'starting') {
362
572
  return;
363
573
  }
364
574
 
575
+ await this.releaseExitedOwnedProcess();
576
+ if (signal.aborted) {
577
+ throw signal.reason;
578
+ }
579
+ if (this.ownedBrowserProcess) {
580
+ throw new Error('A previous owned Chromium process has not been confirmed dead');
581
+ }
582
+
365
583
  this.normalStopRequested = false;
366
584
  this.lastError = undefined;
367
585
  this.viewportRevision = 1;
@@ -377,19 +595,77 @@ export class LiveBrowserSession {
377
595
  if (browserLifetimeController.signal.aborted) {
378
596
  throw browserLifetimeController.signal.reason;
379
597
  }
380
- this.browser = await getEnvAwareBrowserInstance({
381
- forceNoSandbox: this.options.forceNoSandbox,
382
- usePipe: this.options.usePipe,
383
- launchOptions: {
384
- ...this.options.launchOptions,
385
- protocol: 'cdp',
386
- signal: browserLifetimeController.signal,
387
- },
598
+ const abortBrowserLaunch = (): void => {
599
+ if (!browserLifetimeController.signal.aborted) {
600
+ browserLifetimeController.abort(normalizeAbortReason(signal));
601
+ }
602
+ };
603
+ signal.addEventListener('abort', abortBrowserLaunch, { once: true });
604
+ try {
605
+ this.browser = await getEnvAwareBrowserInstance({
606
+ forceNoSandbox: this.options.forceNoSandbox,
607
+ requireSandbox: this.options.requireSandbox,
608
+ usePipe: this.options.usePipe,
609
+ launchOptions: {
610
+ ...this.options.launchOptions,
611
+ protocol: 'cdp',
612
+ signal: browserLifetimeController.signal,
613
+ },
614
+ });
615
+ } finally {
616
+ signal.removeEventListener('abort', abortBrowserLaunch);
617
+ }
618
+ const childProcess = this.browser.process();
619
+ if (!childProcess?.pid) {
620
+ throw new Error('LiveBrowserSession did not receive an owned Chromium process');
621
+ }
622
+ const exitPromise = new Promise<void>((resolve) => {
623
+ if (childProcess.exitCode !== null || childProcess.signalCode !== null) {
624
+ resolve();
625
+ return;
626
+ }
627
+ childProcess.once('exit', () => resolve());
388
628
  });
629
+ this.processGeneration += 1;
630
+ this.ownedBrowserProcess = {
631
+ generation: this.processGeneration,
632
+ childProcess,
633
+ exitPromise,
634
+ forceSignalled: false,
635
+ };
636
+ const identity = process.platform === 'linux'
637
+ ? await readOwnedProcessIdentity(childProcess.pid)
638
+ : undefined;
639
+ if (process.platform === 'linux' && !identity) {
640
+ throw new Error('The owned Chromium process exited before its identity was captured');
641
+ }
642
+ if (
643
+ identity
644
+ && (identity.processGroupId !== childProcess.pid || identity.sessionId !== childProcess.pid)
645
+ ) {
646
+ throw new Error('Chromium was not launched as a dedicated process-group leader');
647
+ }
648
+ this.ownedBrowserProcess.identity = identity;
389
649
  if (signal.aborted) {
390
650
  throw signal.reason;
391
651
  }
392
652
  this.browserContext = this.browser.defaultBrowserContext();
653
+ this.browserTargetCreatedListener = (target) => {
654
+ if (target.type() !== 'page' || target.browserContext() !== this.browserContext) {
655
+ return;
656
+ }
657
+ void target.page().then((page) => {
658
+ if (page) {
659
+ this.scheduleDiscoveredPageRegistration(page);
660
+ }
661
+ }).catch((error) => {
662
+ if (!this.normalStopRequested && this.status !== 'stopped' && this.status !== 'stopping') {
663
+ this.handleDiscoveredPageFailure(error);
664
+ }
665
+ });
666
+ };
667
+ this.browser.on('targetcreated', this.browserTargetCreatedListener);
668
+ await this.configureBrowserSecurity(this.browser);
393
669
  this.browserDisconnectedListener = () => {
394
670
  if (this.normalStopRequested || this.status === 'stopped' || this.status === 'stopping') {
395
671
  return;
@@ -432,9 +708,19 @@ export class LiveBrowserSession {
432
708
  this.status = 'running';
433
709
  this.emitState();
434
710
  await this.startScreencast(firstTab);
711
+ if (signal.aborted) {
712
+ throw normalizeAbortReason(signal);
713
+ }
435
714
  } catch (error) {
436
715
  if (signal.aborted || this.normalStopRequested) {
437
- await this.stopInternal();
716
+ try {
717
+ await this.stopInternal();
718
+ } catch (cleanupError) {
719
+ throw new AggregateError(
720
+ [error, cleanupError],
721
+ 'Cancelled browser startup cleanup was incomplete',
722
+ );
723
+ }
438
724
  throw error;
439
725
  }
440
726
  const startError: ILiveBrowserError = {
@@ -443,9 +729,19 @@ export class LiveBrowserSession {
443
729
  fatal: true,
444
730
  };
445
731
  this.emitError(startError);
446
- await this.stopInternal(startError);
732
+ try {
733
+ await this.stopInternal(startError);
734
+ } catch (cleanupError) {
735
+ throw new AggregateError(
736
+ [error, cleanupError],
737
+ 'Browser startup failed and cleanup was incomplete',
738
+ );
739
+ }
447
740
  throw error;
448
741
  }
742
+ }, operationOptions);
743
+ return startPromise.finally(() => {
744
+ this.startRequestCount -= 1;
449
745
  });
450
746
  }
451
747
 
@@ -508,6 +804,7 @@ export class LiveBrowserSession {
508
804
 
509
805
  public async createTab(
510
806
  optionsArg: ILiveBrowserCreateTabOptions = {},
807
+ operationOptions: ILiveBrowserOperationOptions = {},
511
808
  ): Promise<ILiveBrowserTabState> {
512
809
  const url = optionsArg.url === undefined ? undefined : this.validateUrl(optionsArg.url);
513
810
  const activate = optionsArg.activate ?? true;
@@ -563,16 +860,22 @@ export class LiveBrowserSession {
563
860
  }
564
861
  throw error;
565
862
  }
566
- });
863
+ }, operationOptions);
567
864
  }
568
865
 
569
- public async activateTab(tabId: string): Promise<void> {
866
+ public async activateTab(
867
+ tabId: string,
868
+ operationOptions: ILiveBrowserOperationOptions = {},
869
+ ): Promise<void> {
570
870
  return this.enqueuePublicOperation(async () => {
571
871
  await this.activateTabInternal(tabId);
572
- });
872
+ }, operationOptions);
573
873
  }
574
874
 
575
- public async closeTab(tabId: string): Promise<void> {
875
+ public async closeTab(
876
+ tabId: string,
877
+ operationOptions: ILiveBrowserOperationOptions = {},
878
+ ): Promise<void> {
576
879
  return this.enqueuePublicOperation(async () => {
577
880
  const tab = this.requireTab(tabId);
578
881
  const wasActive = this.activeTabId === tab.id;
@@ -609,10 +912,13 @@ export class LiveBrowserSession {
609
912
  } else {
610
913
  this.emitState();
611
914
  }
612
- });
915
+ }, operationOptions);
613
916
  }
614
917
 
615
- public async navigate(optionsArg: ILiveBrowserNavigateOptions): Promise<void> {
918
+ public async navigate(
919
+ optionsArg: ILiveBrowserNavigateOptions,
920
+ operationOptions: ILiveBrowserOperationOptions = {},
921
+ ): Promise<void> {
616
922
  const url = this.validateUrl(optionsArg.url);
617
923
  return this.enqueuePublicOperation(async (signal) => {
618
924
  const tab = this.resolveNavigationTab(optionsArg.tabId);
@@ -623,10 +929,13 @@ export class LiveBrowserSession {
623
929
  },
624
930
  signal,
625
931
  );
626
- });
932
+ }, operationOptions);
627
933
  }
628
934
 
629
- public async back(optionsArg: ILiveBrowserNavigationOptions = {}): Promise<void> {
935
+ public async back(
936
+ optionsArg: ILiveBrowserNavigationOptions = {},
937
+ operationOptions: ILiveBrowserOperationOptions = {},
938
+ ): Promise<void> {
630
939
  return this.enqueuePublicOperation(async (signal) => {
631
940
  const tab = this.resolveNavigationTab(optionsArg.tabId);
632
941
  await this.navigateTab(
@@ -636,10 +945,13 @@ export class LiveBrowserSession {
636
945
  },
637
946
  signal,
638
947
  );
639
- });
948
+ }, operationOptions);
640
949
  }
641
950
 
642
- public async forward(optionsArg: ILiveBrowserNavigationOptions = {}): Promise<void> {
951
+ public async forward(
952
+ optionsArg: ILiveBrowserNavigationOptions = {},
953
+ operationOptions: ILiveBrowserOperationOptions = {},
954
+ ): Promise<void> {
643
955
  return this.enqueuePublicOperation(async (signal) => {
644
956
  const tab = this.resolveNavigationTab(optionsArg.tabId);
645
957
  await this.navigateTab(
@@ -649,10 +961,13 @@ export class LiveBrowserSession {
649
961
  },
650
962
  signal,
651
963
  );
652
- });
964
+ }, operationOptions);
653
965
  }
654
966
 
655
- public async reload(optionsArg: ILiveBrowserNavigationOptions = {}): Promise<void> {
967
+ public async reload(
968
+ optionsArg: ILiveBrowserNavigationOptions = {},
969
+ operationOptions: ILiveBrowserOperationOptions = {},
970
+ ): Promise<void> {
656
971
  return this.enqueuePublicOperation(async (signal) => {
657
972
  const tab = this.resolveNavigationTab(optionsArg.tabId);
658
973
  await this.navigateTab(
@@ -662,10 +977,13 @@ export class LiveBrowserSession {
662
977
  },
663
978
  signal,
664
979
  );
665
- });
980
+ }, operationOptions);
666
981
  }
667
982
 
668
- public async setViewport(viewportArg: ILiveBrowserViewport): Promise<void> {
983
+ public async setViewport(
984
+ viewportArg: ILiveBrowserViewport,
985
+ operationOptions: ILiveBrowserOperationOptions = {},
986
+ ): Promise<void> {
669
987
  const viewport = normalizeViewport(viewportArg);
670
988
  return this.enqueuePublicOperation(async (signal) => {
671
989
  const tab = this.requireActiveTab();
@@ -684,7 +1002,7 @@ export class LiveBrowserSession {
684
1002
  await this.startScreencast(tab);
685
1003
  }
686
1004
  }
687
- });
1005
+ }, operationOptions);
688
1006
  }
689
1007
 
690
1008
  public async dispatchMouse(input: ILiveBrowserMouseInput): Promise<void> {
@@ -815,6 +1133,7 @@ export class LiveBrowserSession {
815
1133
 
816
1134
  public async captureSnapshot(
817
1135
  optionsArg: ILiveBrowserSnapshotOptions = {},
1136
+ operationOptions: ILiveBrowserOperationOptions = {},
818
1137
  ): Promise<ILiveBrowserSnapshot> {
819
1138
  const format = optionsArg.format ?? 'jpeg';
820
1139
  if (format !== 'jpeg' && format !== 'png') {
@@ -858,10 +1177,13 @@ export class LiveBrowserSession {
858
1177
  ...dimensions,
859
1178
  data,
860
1179
  };
861
- });
1180
+ }, operationOptions);
862
1181
  }
863
1182
 
864
- public async observe(optionsArg: ILiveBrowserObserveOptions = {}): Promise<ILiveBrowserObservation> {
1183
+ public async observe(
1184
+ optionsArg: ILiveBrowserObserveOptions = {},
1185
+ operationOptions: ILiveBrowserOperationOptions = {},
1186
+ ): Promise<ILiveBrowserObservation> {
865
1187
  const maxCharacters = optionsArg.maxCharacters === undefined
866
1188
  ? 12000
867
1189
  : validateInteger(optionsArg.maxCharacters, 'maxCharacters', 256, 50000);
@@ -948,10 +1270,203 @@ export class LiveBrowserSession {
948
1270
  text: truncatedText,
949
1271
  truncated: reachedTraversalLimit || unboundedText.length > maxCharacters,
950
1272
  };
951
- });
1273
+ }, operationOptions);
1274
+ }
1275
+
1276
+ public async evaluate(
1277
+ expressionArg: string,
1278
+ optionsArg: ILiveBrowserEvaluateOptions = {},
1279
+ operationOptions: ILiveBrowserOperationOptions = {},
1280
+ ): Promise<TLiveBrowserJsonValue> {
1281
+ if (!this.options.allowEvaluation) {
1282
+ throw new Error('LiveBrowserSession evaluation is disabled');
1283
+ }
1284
+ const expression = validateBoundedString(
1285
+ expressionArg,
1286
+ 'expression',
1287
+ 1,
1288
+ maxEvaluationScriptBytes,
1289
+ );
1290
+ const expressionBytes = new TextEncoder().encode(expression).byteLength;
1291
+ if (expressionBytes > maxEvaluationScriptBytes) {
1292
+ throw new Error(`expression must not exceed ${maxEvaluationScriptBytes} UTF-8 bytes`);
1293
+ }
1294
+ const evaluationOptions = this.normalizeEvaluationOptions(optionsArg);
1295
+ const evaluationId = ++this.evaluationSequence;
1296
+ const cancellationKey = `__smartpuppeteerCancel${evaluationId}`;
1297
+ const evaluationExpression = this.createEvaluationExpression(
1298
+ expression,
1299
+ evaluationOptions,
1300
+ cancellationKey,
1301
+ );
1302
+
1303
+ return this.enqueuePublicOperation(async (signal) => {
1304
+ const tab = this.resolveActionTab(optionsArg.tabId);
1305
+ const cdpSession = await tab.page.createCDPSession();
1306
+ this.allowOperationalCdpSessionDetach(cdpSession);
1307
+ let executionContextId: number | undefined;
1308
+ let cancellationPromise: Promise<void> | undefined;
1309
+ let terminationPromise: Promise<void> | undefined;
1310
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
1311
+ let evaluationTimedOut = false;
1312
+ const terminateEvaluation = (): void => {
1313
+ terminationPromise ??= cdpSession.send('Runtime.terminateExecution').then(() => undefined);
1314
+ };
1315
+ const cancelEvaluation = (): void => {
1316
+ if (executionContextId === undefined) {
1317
+ return;
1318
+ }
1319
+ cancellationPromise ??= cdpSession.send('Runtime.evaluate', {
1320
+ expression: `globalThis[${JSON.stringify(evaluationCancelKey)}]?.(${JSON.stringify(cancellationKey)})`,
1321
+ contextId: executionContextId,
1322
+ returnByValue: true,
1323
+ awaitPromise: false,
1324
+ includeCommandLineAPI: false,
1325
+ userGesture: false,
1326
+ disableBreaks: true,
1327
+ }).then(() => undefined);
1328
+ };
1329
+ signal.addEventListener('abort', cancelEvaluation, { once: true });
1330
+ let evaluationError: unknown;
1331
+ let evaluationFailed = false;
1332
+ try {
1333
+ if (signal.aborted) {
1334
+ throw normalizeAbortReason(signal);
1335
+ }
1336
+ executionContextId = tab.evaluationExecutionContextId;
1337
+ if (executionContextId === undefined) {
1338
+ const frameTreeResponse = await cdpSession.send('Page.getFrameTree');
1339
+ if (signal.aborted) {
1340
+ throw normalizeAbortReason(signal);
1341
+ }
1342
+ const isolatedWorld = await cdpSession.send('Page.createIsolatedWorld', {
1343
+ frameId: frameTreeResponse.frameTree.frame.id,
1344
+ worldName: 'smartpuppeteer-evaluation',
1345
+ grantUniveralAccess: false,
1346
+ });
1347
+ executionContextId = isolatedWorld.executionContextId;
1348
+ tab.evaluationExecutionContextId = executionContextId;
1349
+ }
1350
+ if (signal.aborted) {
1351
+ throw normalizeAbortReason(signal);
1352
+ }
1353
+ const bootstrapResponse = await cdpSession.send('Runtime.evaluate', {
1354
+ expression: this.createEvaluationBootstrapExpression(),
1355
+ contextId: executionContextId,
1356
+ returnByValue: true,
1357
+ awaitPromise: true,
1358
+ includeCommandLineAPI: false,
1359
+ userGesture: false,
1360
+ disableBreaks: true,
1361
+ });
1362
+ if (bootstrapResponse.exceptionDetails) {
1363
+ throw new Error(this.readCdpExceptionMessage(bootstrapResponse.exceptionDetails));
1364
+ }
1365
+ if (signal.aborted) {
1366
+ throw normalizeAbortReason(signal);
1367
+ }
1368
+ timeoutHandle = setTimeout(() => {
1369
+ evaluationTimedOut = true;
1370
+ terminateEvaluation();
1371
+ }, evaluationOptions.timeoutMs + 250);
1372
+ const evaluationResponse = await cdpSession.send('Runtime.evaluate', {
1373
+ expression: evaluationExpression,
1374
+ contextId: executionContextId,
1375
+ returnByValue: true,
1376
+ awaitPromise: true,
1377
+ timeout: evaluationOptions.timeoutMs + 250,
1378
+ includeCommandLineAPI: false,
1379
+ userGesture: false,
1380
+ disableBreaks: true,
1381
+ allowUnsafeEvalBlockedByCSP: true,
1382
+ });
1383
+ if (evaluationTimedOut) {
1384
+ throw new Error(`Evaluation timed out after ${evaluationOptions.timeoutMs}ms`);
1385
+ }
1386
+ if (signal.aborted) {
1387
+ throw normalizeAbortReason(signal);
1388
+ }
1389
+ if (evaluationResponse.exceptionDetails) {
1390
+ throw new Error(this.readCdpExceptionMessage(evaluationResponse.exceptionDetails));
1391
+ }
1392
+ const envelope = evaluationResponse.result.value as IEvaluationEnvelope | undefined;
1393
+ if (!envelope || typeof envelope !== 'object' || typeof envelope.ok !== 'boolean') {
1394
+ throw new Error('Evaluation returned an invalid result envelope');
1395
+ }
1396
+ if (!envelope.ok) {
1397
+ throw new Error(
1398
+ typeof envelope.error === 'string'
1399
+ ? truncate(envelope.error, 2048)
1400
+ : 'Evaluation failed',
1401
+ );
1402
+ }
1403
+ if (typeof envelope.json !== 'string') {
1404
+ throw new Error('Evaluation returned an invalid JSON result');
1405
+ }
1406
+ if (new TextEncoder().encode(envelope.json).byteLength > evaluationOptions.maxOutputBytes) {
1407
+ throw new Error('Evaluation result exceeded maxOutputBytes during transfer');
1408
+ }
1409
+ return JSON.parse(envelope.json) as TLiveBrowserJsonValue;
1410
+ } catch (error) {
1411
+ evaluationError = error;
1412
+ evaluationFailed = true;
1413
+ throw error;
1414
+ } finally {
1415
+ signal.removeEventListener('abort', cancelEvaluation);
1416
+ if (timeoutHandle) {
1417
+ clearTimeout(timeoutHandle);
1418
+ }
1419
+ const cleanupErrors: unknown[] = [];
1420
+ if (cancellationPromise) {
1421
+ try {
1422
+ await cancellationPromise;
1423
+ } catch (error) {
1424
+ cleanupErrors.push(error);
1425
+ }
1426
+ }
1427
+ if (terminationPromise) {
1428
+ try {
1429
+ await terminationPromise;
1430
+ } catch (error) {
1431
+ cleanupErrors.push(error);
1432
+ }
1433
+ }
1434
+ if (executionContextId !== undefined && !cdpSession.detached) {
1435
+ try {
1436
+ await cdpSession.send('Runtime.evaluate', {
1437
+ expression: `globalThis[${JSON.stringify(evaluationCleanupKey)}]?.(${JSON.stringify(cancellationKey)})`,
1438
+ contextId: executionContextId,
1439
+ returnByValue: true,
1440
+ awaitPromise: false,
1441
+ includeCommandLineAPI: false,
1442
+ userGesture: false,
1443
+ disableBreaks: true,
1444
+ });
1445
+ } catch {
1446
+ // Navigation destroys the old execution context and its cancellation registry.
1447
+ }
1448
+ }
1449
+ if (!cdpSession.detached) {
1450
+ try {
1451
+ await cdpSession.detach();
1452
+ } catch (error) {
1453
+ cleanupErrors.push(error);
1454
+ }
1455
+ }
1456
+ if (cleanupErrors.length > 0) {
1457
+ throw new AggregateError(
1458
+ evaluationFailed ? [evaluationError, ...cleanupErrors] : cleanupErrors,
1459
+ 'Evaluation cleanup was incomplete',
1460
+ );
1461
+ }
1462
+ }
1463
+ }, operationOptions);
952
1464
  }
953
1465
 
954
- public async click(optionsArg: ILiveBrowserClickOptions): Promise<void> {
1466
+ public async click(
1467
+ optionsArg: ILiveBrowserClickOptions,
1468
+ operationOptions: ILiveBrowserOperationOptions = {},
1469
+ ): Promise<void> {
955
1470
  const selector = validateBoundedString(
956
1471
  optionsArg.selector,
957
1472
  'selector',
@@ -977,10 +1492,13 @@ export class LiveBrowserSession {
977
1492
  });
978
1493
  await this.refreshTab(actionTab);
979
1494
  this.emitState();
980
- });
1495
+ }, operationOptions);
981
1496
  }
982
1497
 
983
- public async fill(optionsArg: ILiveBrowserFillOptions): Promise<void> {
1498
+ public async fill(
1499
+ optionsArg: ILiveBrowserFillOptions,
1500
+ operationOptions: ILiveBrowserOperationOptions = {},
1501
+ ): Promise<void> {
984
1502
  const selector = validateBoundedString(
985
1503
  optionsArg.selector,
986
1504
  'selector',
@@ -996,10 +1514,13 @@ export class LiveBrowserSession {
996
1514
  await actionTab.page.locator(selector).setTimeout(timeout).fill(text, { signal });
997
1515
  await this.refreshTab(actionTab);
998
1516
  this.emitState();
999
- });
1517
+ }, operationOptions);
1000
1518
  }
1001
1519
 
1002
- public async press(optionsArg: ILiveBrowserPressOptions): Promise<void> {
1520
+ public async press(
1521
+ optionsArg: ILiveBrowserPressOptions,
1522
+ operationOptions: ILiveBrowserOperationOptions = {},
1523
+ ): Promise<void> {
1003
1524
  const selector = validateBoundedString(
1004
1525
  optionsArg.selector,
1005
1526
  'selector',
@@ -1027,12 +1548,27 @@ export class LiveBrowserSession {
1027
1548
  }
1028
1549
  await this.refreshTab(actionTab);
1029
1550
  this.emitState();
1030
- });
1551
+ }, operationOptions);
1031
1552
  }
1032
1553
 
1033
1554
  private enqueuePublicOperation<T>(
1034
1555
  operation: (signal: AbortSignal) => Promise<T>,
1556
+ operationOptions: ILiveBrowserOperationOptions = {},
1035
1557
  ): Promise<T> {
1558
+ const callerSignal = operationOptions.signal;
1559
+ if (
1560
+ callerSignal !== undefined
1561
+ && (
1562
+ typeof callerSignal !== 'object'
1563
+ || typeof callerSignal.addEventListener !== 'function'
1564
+ || typeof callerSignal.removeEventListener !== 'function'
1565
+ )
1566
+ ) {
1567
+ return Promise.reject(new Error('operationOptions.signal must be an AbortSignal'));
1568
+ }
1569
+ if (callerSignal?.aborted) {
1570
+ return Promise.reject(normalizeAbortReason(callerSignal));
1571
+ }
1036
1572
  if (
1037
1573
  this.status === 'stopping'
1038
1574
  || this.normalStopRequested
@@ -1044,7 +1580,25 @@ export class LiveBrowserSession {
1044
1580
  return Promise.reject(new Error('LiveBrowserSession operation queue is full'));
1045
1581
  }
1046
1582
  this.admittedPublicOperations += 1;
1047
- return this.enqueueQueuedOperation('public', operation);
1583
+ return this.enqueueQueuedOperation('public', async (signal) => {
1584
+ this.assertProxySecurityReady();
1585
+ return operation(signal);
1586
+ }, false, callerSignal);
1587
+ }
1588
+
1589
+ private assertProxySecurityReady(): void {
1590
+ if (!this.options.security?.proxyCredentials || this.status !== 'running') {
1591
+ return;
1592
+ }
1593
+ if (
1594
+ !this.browserSecurityConnection
1595
+ || !this.browserSecurityCdpSession
1596
+ || this.browserSecurityCdpSession.detached
1597
+ ) {
1598
+ const error = new Error('Authenticated proxy security is not attached');
1599
+ this.handleProxySecurityFailure('proxy_security_unavailable', error);
1600
+ throw error;
1601
+ }
1048
1602
  }
1049
1603
 
1050
1604
  private enqueueInternalOperation(
@@ -1062,15 +1616,45 @@ export class LiveBrowserSession {
1062
1616
  kind: TQueuedOperationKind,
1063
1617
  operation: (signal: AbortSignal) => Promise<T>,
1064
1618
  priority = false,
1619
+ callerSignal?: AbortSignal,
1065
1620
  ): Promise<T> {
1066
1621
  return new Promise<T>((resolve, reject) => {
1067
1622
  const queuedOperation: IQueuedOperation = {
1068
1623
  kind,
1624
+ state: 'queued',
1069
1625
  controller: new AbortController(),
1070
1626
  run: operation,
1071
1627
  resolve: (value) => resolve(value as T),
1072
1628
  reject,
1629
+ capacityReleased: false,
1073
1630
  };
1631
+ if (callerSignal) {
1632
+ const onCallerAbort = (): void => {
1633
+ const abortReason = normalizeAbortReason(callerSignal);
1634
+ if (queuedOperation.state === 'queued') {
1635
+ const operationIndex = this.operationQueue.indexOf(queuedOperation);
1636
+ if (operationIndex >= 0) {
1637
+ this.operationQueue.splice(operationIndex, 1);
1638
+ }
1639
+ queuedOperation.controller.abort(abortReason);
1640
+ queuedOperation.reject(abortReason);
1641
+ this.finalizeQueuedOperation(queuedOperation);
1642
+ this.drainOperationQueue();
1643
+ } else if (queuedOperation.state === 'active') {
1644
+ queuedOperation.controller.abort(abortReason);
1645
+ }
1646
+ };
1647
+ callerSignal.addEventListener('abort', onCallerAbort, { once: true });
1648
+ queuedOperation.removeCallerAbortListener = () => {
1649
+ callerSignal.removeEventListener('abort', onCallerAbort);
1650
+ };
1651
+ if (callerSignal.aborted) {
1652
+ onCallerAbort();
1653
+ }
1654
+ }
1655
+ if (queuedOperation.state !== 'queued') {
1656
+ return;
1657
+ }
1074
1658
  if (priority) {
1075
1659
  this.operationQueue.unshift(queuedOperation);
1076
1660
  } else {
@@ -1090,6 +1674,7 @@ export class LiveBrowserSession {
1090
1674
  }
1091
1675
  this.operationRunning = true;
1092
1676
  this.activeOperation = queuedOperation;
1677
+ queuedOperation.state = 'active';
1093
1678
  void this.executeQueuedOperation(queuedOperation).catch((error) => {
1094
1679
  this.operationRunning = false;
1095
1680
  this.activeOperation = undefined;
@@ -1107,15 +1692,15 @@ export class LiveBrowserSession {
1107
1692
  if (queuedOperation.controller.signal.aborted) {
1108
1693
  throw queuedOperation.controller.signal.reason;
1109
1694
  }
1110
- queuedOperation.resolve(await queuedOperation.run(queuedOperation.controller.signal));
1695
+ const value = await queuedOperation.run(queuedOperation.controller.signal);
1696
+ if (queuedOperation.controller.signal.aborted) {
1697
+ throw queuedOperation.controller.signal.reason;
1698
+ }
1699
+ queuedOperation.resolve(value);
1111
1700
  } catch (error) {
1112
1701
  queuedOperation.reject(error);
1113
1702
  } finally {
1114
- if (queuedOperation.kind === 'public') {
1115
- this.admittedPublicOperations -= 1;
1116
- } else if (queuedOperation.kind === 'internal') {
1117
- this.admittedInternalOperations -= 1;
1118
- }
1703
+ this.finalizeQueuedOperation(queuedOperation);
1119
1704
  if (this.activeOperation === queuedOperation) {
1120
1705
  this.activeOperation = undefined;
1121
1706
  }
@@ -1130,15 +1715,29 @@ export class LiveBrowserSession {
1130
1715
  }
1131
1716
  }
1132
1717
 
1718
+ private finalizeQueuedOperation(queuedOperation: IQueuedOperation): void {
1719
+ if (queuedOperation.state === 'settled') {
1720
+ return;
1721
+ }
1722
+ queuedOperation.state = 'settled';
1723
+ queuedOperation.removeCallerAbortListener?.();
1724
+ queuedOperation.removeCallerAbortListener = undefined;
1725
+ if (queuedOperation.capacityReleased) {
1726
+ return;
1727
+ }
1728
+ queuedOperation.capacityReleased = true;
1729
+ if (queuedOperation.kind === 'public') {
1730
+ this.admittedPublicOperations -= 1;
1731
+ } else if (queuedOperation.kind === 'internal') {
1732
+ this.admittedInternalOperations -= 1;
1733
+ }
1734
+ }
1735
+
1133
1736
  private cancelQueuedOperations(error: Error): void {
1134
1737
  for (const queuedOperation of this.operationQueue.splice(0)) {
1135
1738
  queuedOperation.controller.abort(error);
1136
- if (queuedOperation.kind === 'public') {
1137
- this.admittedPublicOperations -= 1;
1138
- } else if (queuedOperation.kind === 'internal') {
1139
- this.admittedInternalOperations -= 1;
1140
- }
1141
1739
  queuedOperation.reject(error);
1740
+ this.finalizeQueuedOperation(queuedOperation);
1142
1741
  }
1143
1742
  }
1144
1743
 
@@ -1162,7 +1761,8 @@ export class LiveBrowserSession {
1162
1761
  }
1163
1762
 
1164
1763
  private requestShutdown(error?: ILiveBrowserError): Promise<void> {
1165
- if (this.status === 'stopped') {
1764
+ if (this.status === 'stopped' && !this.operationRunning && this.startRequestCount === 0) {
1765
+ this.normalStopRequested = false;
1166
1766
  return Promise.resolve();
1167
1767
  }
1168
1768
  if (this.shutdownPromise) {
@@ -1275,57 +1875,325 @@ export class LiveBrowserSession {
1275
1875
  };
1276
1876
  }
1277
1877
 
1278
- private async stopInternal(error?: ILiveBrowserError): Promise<void> {
1279
- if (this.status === 'stopped') {
1878
+ private async releaseExitedOwnedProcess(): Promise<void> {
1879
+ const ownedProcess = this.ownedBrowserProcess;
1880
+ if (!ownedProcess) {
1280
1881
  return;
1281
1882
  }
1282
- this.status = 'stopping';
1283
- if (error) {
1284
- this.lastError = { ...error };
1883
+ if (
1884
+ ownedProcess.childProcess.exitCode === null
1885
+ && ownedProcess.childProcess.signalCode === null
1886
+ ) {
1887
+ await Promise.race([ownedProcess.exitPromise, delay(100)]);
1888
+ if (
1889
+ ownedProcess.childProcess.exitCode === null
1890
+ && ownedProcess.childProcess.signalCode === null
1891
+ ) {
1892
+ return;
1893
+ }
1285
1894
  }
1286
- this.emitState();
1287
- if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
1288
- this.browserLifetimeController.abort(new Error('LiveBrowserSession is stopping'));
1895
+ if (process.platform === 'linux' && !ownedProcess.identity) {
1896
+ return;
1289
1897
  }
1290
-
1291
- const browser = this.browser;
1292
- if (browser && this.browserDisconnectedListener) {
1293
- browser.off('disconnected', this.browserDisconnectedListener);
1898
+ if (ownedProcess.identity) {
1899
+ const members = await listOwnedProcessGroupMembers(ownedProcess.identity);
1900
+ if (members.length > 0) {
1901
+ return;
1902
+ }
1294
1903
  }
1295
- this.browserDisconnectedListener = undefined;
1904
+ this.ownedBrowserProcess = undefined;
1905
+ }
1296
1906
 
1297
- const shutdownErrors: unknown[] = [];
1298
- for (const tab of [...this.tabs.values()]) {
1299
- tab.closing = true;
1300
- try {
1301
- await this.stopScreencast(tab);
1302
- } catch (cleanupError) {
1303
- shutdownErrors.push(cleanupError);
1907
+ private async terminateInternal(
1908
+ gracefulTimeoutMs: number,
1909
+ forceTimeoutMs: number,
1910
+ ): Promise<ILiveBrowserTerminationResult> {
1911
+ const errors: string[] = [];
1912
+ let shutdownSettled = this.status === 'stopped';
1913
+ const shutdownObserved = this.stop().then(
1914
+ () => {
1915
+ shutdownSettled = true;
1916
+ },
1917
+ (error) => {
1918
+ errors.push(normalizeErrorMessage(error));
1919
+ shutdownSettled = true;
1920
+ },
1921
+ );
1922
+ const createConfirmedResult = (
1923
+ ownedProcess: IOwnedBrowserProcess | undefined,
1924
+ forced: boolean,
1925
+ ): ILiveBrowserTerminationResult => {
1926
+ const childProcess = ownedProcess?.childProcess;
1927
+ const result: ILiveBrowserTerminationResult = {
1928
+ generation: ownedProcess?.generation ?? this.processGeneration,
1929
+ pid: childProcess?.pid ?? null,
1930
+ processGroupId: ownedProcess?.identity?.processGroupId ?? null,
1931
+ running: false,
1932
+ exitCode: childProcess?.exitCode ?? null,
1933
+ signalCode: childProcess?.signalCode ?? null,
1934
+ forced: forced || Boolean(ownedProcess?.forceSignalled),
1935
+ shutdownComplete: true,
1936
+ confirmedDead: true,
1937
+ errors,
1938
+ };
1939
+ if (this.ownedBrowserProcess === ownedProcess) {
1940
+ this.ownedBrowserProcess = undefined;
1304
1941
  }
1305
- try {
1306
- this.removePageListeners(tab);
1307
- } catch (cleanupError) {
1308
- shutdownErrors.push(cleanupError);
1942
+ return result;
1943
+ };
1944
+ const isConfirmedDead = async (
1945
+ ownedProcess: IOwnedBrowserProcess | undefined,
1946
+ ): Promise<boolean> => {
1947
+ if (!ownedProcess) {
1948
+ return true;
1309
1949
  }
1310
- }
1311
- try {
1312
- await this.retireOutstandingFrames(() => true);
1313
- } catch (cleanupError) {
1314
- shutdownErrors.push(cleanupError);
1315
- }
1950
+ if (process.platform !== 'linux' || !ownedProcess.identity) {
1951
+ if (process.platform === 'linux' && this.startRequestCount > 0) {
1952
+ return false;
1953
+ }
1954
+ throw new Error('Confirmed browser process-group termination is supported on Linux only');
1955
+ }
1956
+ if (
1957
+ ownedProcess.childProcess.exitCode === null
1958
+ && ownedProcess.childProcess.signalCode === null
1959
+ ) {
1960
+ return false;
1961
+ }
1962
+ const members = await listOwnedProcessGroupMembers(ownedProcess.identity);
1963
+ if (members.length > 0) {
1964
+ return false;
1965
+ }
1966
+ return true;
1967
+ };
1316
1968
 
1317
- if (browser) {
1318
- try {
1319
- await browser.close();
1320
- } catch (closeError) {
1321
- shutdownErrors.push(closeError);
1969
+ const gracefulDeadline = Date.now() + gracefulTimeoutMs;
1970
+ while (Date.now() < gracefulDeadline) {
1971
+ const ownedProcess = this.ownedBrowserProcess;
1972
+ if (
1973
+ await isConfirmedDead(ownedProcess)
1974
+ && shutdownSettled
1975
+ && this.status === 'stopped'
1976
+ ) {
1977
+ return createConfirmedResult(ownedProcess, false);
1978
+ }
1979
+ if (shutdownSettled) {
1980
+ await delay(100);
1981
+ } else {
1982
+ await Promise.race([shutdownObserved, delay(100)]);
1322
1983
  }
1323
1984
  }
1324
1985
 
1325
- for (const tab of this.tabs.values()) {
1326
- this.tabIdsByPage.delete(tab.page);
1986
+ const ownedProcess = this.ownedBrowserProcess;
1987
+ let forced = false;
1988
+ if (ownedProcess) {
1989
+ if (process.platform !== 'linux') {
1990
+ throw new Error('Confirmed browser process-group termination is supported on Linux only');
1991
+ }
1992
+ if (ownedProcess.identity) {
1993
+ const membersBeforeFreeze = await listOwnedProcessGroupMembers(ownedProcess.identity);
1994
+ if (membersBeforeFreeze.length > 0) {
1995
+ forced = true;
1996
+ const groupStopped = await signalOwnedProcessGroup(ownedProcess.identity, 'SIGSTOP');
1997
+ if (groupStopped) {
1998
+ killFrozenOwnedProcessGroup(ownedProcess.identity);
1999
+ } else {
2000
+ await this.killOwnedProcessGroupSurvivors(ownedProcess.identity, membersBeforeFreeze);
2001
+ }
2002
+ }
2003
+ } else if (this.startRequestCount === 0) {
2004
+ throw new Error('Owned Chromium process identity was never confirmed');
2005
+ }
1327
2006
  }
1328
- this.tabs.clear();
2007
+
2008
+ const forceDeadline = Date.now() + forceTimeoutMs;
2009
+ while (Date.now() < forceDeadline) {
2010
+ const currentOwnedProcess = this.ownedBrowserProcess;
2011
+ if (
2012
+ await isConfirmedDead(currentOwnedProcess)
2013
+ && shutdownSettled
2014
+ && this.status === 'stopped'
2015
+ ) {
2016
+ return createConfirmedResult(currentOwnedProcess, forced);
2017
+ }
2018
+ if (shutdownSettled) {
2019
+ await delay(100);
2020
+ } else {
2021
+ await Promise.race([shutdownObserved, delay(100)]);
2022
+ }
2023
+ }
2024
+
2025
+ const remainingOwnedProcess = this.ownedBrowserProcess;
2026
+ if (remainingOwnedProcess?.identity) {
2027
+ const survivors = await listOwnedProcessGroupMembers(remainingOwnedProcess.identity);
2028
+ if (survivors.length > 0) {
2029
+ throw new Error(
2030
+ `Owned Chromium process group did not terminate: ${survivors.map((item) => item.pid).join(', ')}`,
2031
+ );
2032
+ }
2033
+ }
2034
+ if (!shutdownSettled || this.status !== 'stopped') {
2035
+ throw new Error(
2036
+ 'Owned Chromium process group exited but LiveBrowserSession shutdown did not settle',
2037
+ );
2038
+ }
2039
+ throw new Error('Owned Chromium process exit was not confirmed by Node.js');
2040
+ }
2041
+
2042
+ private async killOwnedProcessGroupSurvivors(
2043
+ rootIdentity: IOwnedProcessIdentity,
2044
+ initialMembers: IOwnedProcessIdentity[],
2045
+ ): Promise<void> {
2046
+ let previousKeys = new Set(initialMembers.map((member) => `${member.pid}:${member.startTime}`));
2047
+ for (let attempt = 0; attempt < 20; attempt += 1) {
2048
+ const members = await listOwnedProcessGroupMembers(rootIdentity);
2049
+ if (members.length === 0) {
2050
+ return;
2051
+ }
2052
+ for (const member of members) {
2053
+ await signalOwnedProcessIdentity(member, 'SIGSTOP');
2054
+ }
2055
+ await delay(10);
2056
+ const frozenMembers = await listOwnedProcessGroupMembers(rootIdentity);
2057
+ const currentKeys = new Set(
2058
+ frozenMembers.map((member) => `${member.pid}:${member.startTime}`),
2059
+ );
2060
+ const sameMembers = currentKeys.size === previousKeys.size
2061
+ && [...currentKeys].every((key) => previousKeys.has(key));
2062
+ const allFrozen = frozenMembers.every((member) => (
2063
+ member.state === 'T' || member.state === 't' || member.state === 'Z'
2064
+ ));
2065
+ if (sameMembers && allFrozen) {
2066
+ for (const member of frozenMembers) {
2067
+ await signalOwnedProcessIdentity(member, 'SIGKILL');
2068
+ }
2069
+ return;
2070
+ }
2071
+ previousKeys = currentKeys;
2072
+ }
2073
+ throw new Error('Unable to stabilize the surviving Chromium process group');
2074
+ }
2075
+
2076
+ private async forceOwnedBrowserProcessGroup(
2077
+ ownedProcess: IOwnedBrowserProcess,
2078
+ ): Promise<void> {
2079
+ ownedProcess.forceSignalled = true;
2080
+ if (process.platform !== 'linux') {
2081
+ ownedProcess.childProcess.kill('SIGKILL');
2082
+ await Promise.race([ownedProcess.exitPromise, delay(5000)]);
2083
+ if (
2084
+ ownedProcess.childProcess.exitCode === null
2085
+ && ownedProcess.childProcess.signalCode === null
2086
+ ) {
2087
+ throw new Error('Owned Chromium process did not exit after SIGKILL');
2088
+ }
2089
+ return;
2090
+ }
2091
+ if (!ownedProcess.identity) {
2092
+ throw new Error('Owned Chromium process identity was never confirmed');
2093
+ }
2094
+ const members = await listOwnedProcessGroupMembers(ownedProcess.identity);
2095
+ if (members.length > 0) {
2096
+ const groupStopped = await signalOwnedProcessGroup(ownedProcess.identity, 'SIGSTOP');
2097
+ if (groupStopped) {
2098
+ killFrozenOwnedProcessGroup(ownedProcess.identity);
2099
+ } else {
2100
+ await this.killOwnedProcessGroupSurvivors(ownedProcess.identity, members);
2101
+ }
2102
+ }
2103
+ const deadline = Date.now() + 5000;
2104
+ while (Date.now() < deadline) {
2105
+ const survivors = await listOwnedProcessGroupMembers(ownedProcess.identity);
2106
+ if (survivors.length === 0) {
2107
+ await Promise.race([ownedProcess.exitPromise, delay(100)]);
2108
+ if (
2109
+ ownedProcess.childProcess.exitCode !== null
2110
+ || ownedProcess.childProcess.signalCode !== null
2111
+ ) {
2112
+ return;
2113
+ }
2114
+ }
2115
+ await delay(100);
2116
+ }
2117
+ throw new Error('Owned Chromium process group survived forced startup cleanup');
2118
+ }
2119
+
2120
+ private async stopInternal(error?: ILiveBrowserError): Promise<void> {
2121
+ if (this.status === 'stopped') {
2122
+ return;
2123
+ }
2124
+ this.status = 'stopping';
2125
+ if (error) {
2126
+ this.lastError = { ...error };
2127
+ }
2128
+ this.emitState();
2129
+ if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
2130
+ this.browserLifetimeController.abort(new Error('LiveBrowserSession is stopping'));
2131
+ }
2132
+
2133
+ const browser = this.browser;
2134
+ if (browser && this.browserTargetCreatedListener) {
2135
+ browser.off('targetcreated', this.browserTargetCreatedListener);
2136
+ }
2137
+ this.browserTargetCreatedListener = undefined;
2138
+ if (browser && this.browserDisconnectedListener) {
2139
+ browser.off('disconnected', this.browserDisconnectedListener);
2140
+ }
2141
+ this.browserDisconnectedListener = undefined;
2142
+
2143
+ const shutdownErrors: unknown[] = [];
2144
+ try {
2145
+ await this.teardownProxySecurity();
2146
+ } catch (cleanupError) {
2147
+ shutdownErrors.push(cleanupError);
2148
+ }
2149
+ for (const tab of [...this.tabs.values()]) {
2150
+ tab.closing = true;
2151
+ const securityCdpSession = tab.securityCdpSession;
2152
+ tab.securityCdpSession = undefined;
2153
+ if (securityCdpSession && !securityCdpSession.detached) {
2154
+ try {
2155
+ await securityCdpSession.detach();
2156
+ } catch {
2157
+ // Browser lifetime cancellation may close the target before explicit detach settles.
2158
+ }
2159
+ }
2160
+ try {
2161
+ await this.stopScreencast(tab);
2162
+ } catch (cleanupError) {
2163
+ shutdownErrors.push(cleanupError);
2164
+ }
2165
+ try {
2166
+ this.removePageListeners(tab);
2167
+ } catch (cleanupError) {
2168
+ shutdownErrors.push(cleanupError);
2169
+ }
2170
+ }
2171
+ try {
2172
+ await this.retireOutstandingFrames(() => true);
2173
+ } catch (cleanupError) {
2174
+ shutdownErrors.push(cleanupError);
2175
+ }
2176
+
2177
+ if (browser) {
2178
+ try {
2179
+ await browser.close();
2180
+ } catch (closeError) {
2181
+ shutdownErrors.push(closeError);
2182
+ const ownedProcess = this.ownedBrowserProcess;
2183
+ if (ownedProcess) {
2184
+ try {
2185
+ await this.forceOwnedBrowserProcessGroup(ownedProcess);
2186
+ } catch (forceError) {
2187
+ shutdownErrors.push(forceError);
2188
+ }
2189
+ }
2190
+ }
2191
+ }
2192
+
2193
+ for (const tab of this.tabs.values()) {
2194
+ this.tabIdsByPage.delete(tab.page);
2195
+ }
2196
+ this.tabs.clear();
1329
2197
  this.outstandingFrames.clear();
1330
2198
  this.browser = undefined;
1331
2199
  this.browserContext = undefined;
@@ -1333,11 +2201,452 @@ export class LiveBrowserSession {
1333
2201
  this.activeTabId = null;
1334
2202
  this.status = 'stopped';
1335
2203
  this.emitState();
1336
- if (shutdownErrors.length > 0 && !error) {
2204
+ if (shutdownErrors.length > 0) {
1337
2205
  throw new AggregateError(shutdownErrors, 'LiveBrowserSession shutdown was incomplete');
1338
2206
  }
1339
2207
  }
1340
2208
 
2209
+ private async configureBrowserSecurity(
2210
+ browser: plugins.puppeteer.Browser,
2211
+ ): Promise<void> {
2212
+ const denyPermissions = this.options.security?.denyPermissions;
2213
+ const proxyCredentials = this.options.security?.proxyCredentials;
2214
+ if (!denyPermissions && !proxyCredentials) {
2215
+ return;
2216
+ }
2217
+ const cdpSession = await browser.target().createCDPSession();
2218
+ if (!proxyCredentials) {
2219
+ try {
2220
+ if (denyPermissions) {
2221
+ await cdpSession.send('Browser.grantPermissions', { permissions: [] });
2222
+ }
2223
+ } finally {
2224
+ if (!cdpSession.detached) {
2225
+ await cdpSession.detach();
2226
+ }
2227
+ }
2228
+ return;
2229
+ }
2230
+
2231
+ const generation = this.ownedBrowserProcess?.generation;
2232
+ if (generation === undefined) {
2233
+ throw new Error('Proxy security requires an owned browser generation');
2234
+ }
2235
+ this.proxySecurityStopping = false;
2236
+ this.proxySecurityGeneration = generation;
2237
+ this.browserSecurityCdpSession = cdpSession;
2238
+ try {
2239
+ if (denyPermissions) {
2240
+ await cdpSession.send('Browser.grantPermissions', { permissions: [] });
2241
+ }
2242
+ const browserSecurityConnection = cdpSession.connection();
2243
+ if (!browserSecurityConnection) {
2244
+ throw new Error('Browser security CDP session has no connection');
2245
+ }
2246
+ this.browserSecurityConnection = browserSecurityConnection;
2247
+ this.browserSecuritySessionAttachedListener = (attachedSession) => {
2248
+ this.trackProxySecurityOperation(
2249
+ this.configureProxySecuritySession(attachedSession, generation),
2250
+ generation,
2251
+ 'proxy_security_session_setup_failed',
2252
+ );
2253
+ };
2254
+ this.browserSecuritySessionDetachedListener = (detachedSession) => {
2255
+ if (detachedSession === cdpSession) {
2256
+ this.handleProxySecurityFailure(
2257
+ 'proxy_security_browser_session_detached',
2258
+ new Error('Browser security CDP session detached unexpectedly'),
2259
+ generation,
2260
+ );
2261
+ return;
2262
+ }
2263
+ this.trackProxySecurityOperation(
2264
+ this.verifyProxySecuritySessionDetached(detachedSession, generation),
2265
+ generation,
2266
+ 'proxy_security_detach_verification_failed',
2267
+ );
2268
+ };
2269
+ browserSecurityConnection.on(
2270
+ 'sessionattached',
2271
+ this.browserSecuritySessionAttachedListener,
2272
+ );
2273
+ browserSecurityConnection.on(
2274
+ 'sessiondetached',
2275
+ this.browserSecuritySessionDetachedListener,
2276
+ );
2277
+
2278
+ for (const target of browser.targets()) {
2279
+ if (!proxySeedTargetTypes.has(target.type())) {
2280
+ continue;
2281
+ }
2282
+ const securitySession = await target.createCDPSession();
2283
+ const securityRecord = this.proxySecuritySessions.get(securitySession.id());
2284
+ if (!securityRecord) {
2285
+ throw new Error(`Proxy security did not observe session ${securitySession.id()}`);
2286
+ }
2287
+ securityRecord.ownedByProxySecurity = true;
2288
+ await securityRecord.setupPromise;
2289
+ }
2290
+ } catch (error) {
2291
+ await this.teardownProxySecurity();
2292
+ throw error;
2293
+ }
2294
+ }
2295
+
2296
+ private configureProxySecuritySession(
2297
+ session: plugins.puppeteer.CDPSession,
2298
+ generation: number,
2299
+ ): Promise<void> {
2300
+ const existing = this.proxySecuritySessions.get(session.id());
2301
+ if (existing) {
2302
+ if (existing.generation !== generation) {
2303
+ return Promise.reject(new Error(`CDP session ${session.id()} crossed browser generations`));
2304
+ }
2305
+ return existing.setupPromise;
2306
+ }
2307
+ if (this.proxySecuritySessions.size >= maxProxySecuritySessions) {
2308
+ return Promise.reject(
2309
+ new Error(`Proxy security exceeded ${maxProxySecuritySessions} CDP sessions`),
2310
+ );
2311
+ }
2312
+
2313
+ const attemptedAuthentications = new Set<string>();
2314
+ const requestIdsByNetworkId = new Map<string, string>();
2315
+ const handleProtocolFailure = (operation: string, error: unknown): void => {
2316
+ this.handleProxySecurityFailure(
2317
+ 'proxy_security_protocol_failed',
2318
+ new Error(`${operation}: ${normalizeErrorMessage(error)}`),
2319
+ generation,
2320
+ );
2321
+ };
2322
+ const authRequiredListener = (event: TProxyAuthRequiredEvent): void => {
2323
+ const credentials = this.options.security?.proxyCredentials;
2324
+ const totalTrackedRequests = [...this.proxySecuritySessions.values()].reduce(
2325
+ (total, record) => total
2326
+ + record.attemptedAuthentications.size
2327
+ + record.requestIdsByNetworkId.size,
2328
+ 0,
2329
+ );
2330
+ const hasTrackingCapacity = attemptedAuthentications.size < maxTrackedProxyRequests
2331
+ && totalTrackedRequests < maxTotalTrackedProxyRequests;
2332
+ const isFirstProxyAttempt = event.authChallenge.source === 'Proxy'
2333
+ && !attemptedAuthentications.has(event.requestId)
2334
+ && hasTrackingCapacity;
2335
+ if (isFirstProxyAttempt) {
2336
+ attemptedAuthentications.add(event.requestId);
2337
+ } else if (
2338
+ event.authChallenge.source === 'Proxy'
2339
+ && !attemptedAuthentications.has(event.requestId)
2340
+ && !hasTrackingCapacity
2341
+ ) {
2342
+ handleProtocolFailure(
2343
+ 'Proxy authentication tracking failed',
2344
+ new Error(`A security session exceeded ${maxTrackedProxyRequests} tracked requests`),
2345
+ );
2346
+ }
2347
+ const authChallengeResponse = isFirstProxyAttempt && credentials
2348
+ ? {
2349
+ response: 'ProvideCredentials' as const,
2350
+ username: credentials.username,
2351
+ password: credentials.password,
2352
+ }
2353
+ : { response: 'CancelAuth' as const };
2354
+ this.trackProxySecurityOperation(
2355
+ session.send('Fetch.continueWithAuth', {
2356
+ requestId: event.requestId,
2357
+ authChallengeResponse,
2358
+ }).then(() => undefined),
2359
+ generation,
2360
+ 'proxy_security_protocol_failed',
2361
+ );
2362
+ };
2363
+ const requestPausedListener = (event: TProxyRequestPausedEvent): void => {
2364
+ if (event.networkId) {
2365
+ const totalTrackedRequests = [...this.proxySecuritySessions.values()].reduce(
2366
+ (total, record) => total
2367
+ + record.attemptedAuthentications.size
2368
+ + record.requestIdsByNetworkId.size,
2369
+ 0,
2370
+ );
2371
+ if (
2372
+ !requestIdsByNetworkId.has(event.networkId)
2373
+ && (
2374
+ requestIdsByNetworkId.size >= maxTrackedProxyRequests
2375
+ || totalTrackedRequests >= maxTotalTrackedProxyRequests
2376
+ )
2377
+ ) {
2378
+ handleProtocolFailure(
2379
+ 'Proxy request tracking failed',
2380
+ new Error(`A security session exceeded ${maxTrackedProxyRequests} tracked requests`),
2381
+ );
2382
+ } else {
2383
+ const previousRequestId = requestIdsByNetworkId.get(event.networkId);
2384
+ if (previousRequestId && previousRequestId !== event.requestId) {
2385
+ attemptedAuthentications.delete(previousRequestId);
2386
+ }
2387
+ requestIdsByNetworkId.set(event.networkId, event.requestId);
2388
+ }
2389
+ }
2390
+ this.trackProxySecurityOperation(
2391
+ session.send('Fetch.continueRequest', { requestId: event.requestId }).then(() => undefined),
2392
+ generation,
2393
+ 'proxy_security_protocol_failed',
2394
+ );
2395
+ };
2396
+ const forgetAuthentication = (networkId: string): void => {
2397
+ const requestId = requestIdsByNetworkId.get(networkId);
2398
+ if (!requestId) {
2399
+ return;
2400
+ }
2401
+ requestIdsByNetworkId.delete(networkId);
2402
+ attemptedAuthentications.delete(requestId);
2403
+ };
2404
+ const loadingFinishedListener = (event: TNetworkLoadingFinishedEvent): void => {
2405
+ forgetAuthentication(event.requestId);
2406
+ };
2407
+ const loadingFailedListener = (event: TNetworkLoadingFailedEvent): void => {
2408
+ forgetAuthentication(event.requestId);
2409
+ };
2410
+ session.on('Fetch.authRequired', authRequiredListener);
2411
+ session.on('Fetch.requestPaused', requestPausedListener);
2412
+ session.on('Network.loadingFinished', loadingFinishedListener);
2413
+ session.on('Network.loadingFailed', loadingFailedListener);
2414
+
2415
+ const proxySecuritySession: IProxySecuritySession = {
2416
+ session,
2417
+ generation,
2418
+ fetchEnabled: false,
2419
+ allowLiveTargetDetach: false,
2420
+ ownedByProxySecurity: false,
2421
+ attemptedAuthentications,
2422
+ requestIdsByNetworkId,
2423
+ authRequiredListener,
2424
+ requestPausedListener,
2425
+ loadingFinishedListener,
2426
+ loadingFailedListener,
2427
+ setupPromise: Promise.resolve(),
2428
+ };
2429
+ const targetInfoPromise = session.send('Target.getTargetInfo');
2430
+ const networkEnablePromise = session.send('Network.enable');
2431
+ const fetchEnablePromise = session.send('Fetch.enable', {
2432
+ handleAuthRequests: true,
2433
+ patterns: [{ urlPattern: '*' }],
2434
+ });
2435
+ const commandResultsPromise = Promise.allSettled([
2436
+ targetInfoPromise,
2437
+ networkEnablePromise,
2438
+ fetchEnablePromise,
2439
+ ]);
2440
+ const setupPromise = (async (): Promise<void> => {
2441
+ try {
2442
+ const [targetInfoResult, networkResult, fetchResult] = await commandResultsPromise;
2443
+ if (targetInfoResult.status === 'rejected') {
2444
+ throw targetInfoResult.reason;
2445
+ }
2446
+ const { targetInfo } = targetInfoResult.value;
2447
+ proxySecuritySession.targetId = targetInfo.targetId;
2448
+ proxySecuritySession.targetType = targetInfo.type;
2449
+ if (fetchResult.status === 'rejected') {
2450
+ const unsupportedTarget = proxyFetchUnsupportedTargetTypes.has(targetInfo.type)
2451
+ && fetchResult.reason instanceof plugins.puppeteer.ProtocolError
2452
+ && fetchResult.reason.originalMessage === "'Fetch.enable' wasn't found";
2453
+ if (unsupportedTarget) {
2454
+ this.removeProxySecuritySession(proxySecuritySession);
2455
+ return;
2456
+ }
2457
+ throw fetchResult.reason;
2458
+ }
2459
+ if (networkResult.status === 'rejected') {
2460
+ throw networkResult.reason;
2461
+ }
2462
+ proxySecuritySession.fetchEnabled = true;
2463
+ } catch (error) {
2464
+ this.removeProxySecuritySession(proxySecuritySession);
2465
+ if (session.detached || this.proxySecurityStopping || this.normalStopRequested) {
2466
+ return;
2467
+ }
2468
+ throw error;
2469
+ }
2470
+ })();
2471
+ proxySecuritySession.setupPromise = setupPromise;
2472
+ this.proxySecuritySessions.set(session.id(), proxySecuritySession);
2473
+ return setupPromise;
2474
+ }
2475
+
2476
+ private async verifyProxySecuritySessionDetached(
2477
+ session: plugins.puppeteer.CDPSession,
2478
+ generation: number,
2479
+ ): Promise<void> {
2480
+ const proxySecuritySession = this.proxySecuritySessions.get(session.id());
2481
+ if (!proxySecuritySession || proxySecuritySession.generation !== generation) {
2482
+ return;
2483
+ }
2484
+ this.removeProxySecuritySession(proxySecuritySession);
2485
+ if (this.proxySecurityStopping || this.normalStopRequested || this.status !== 'running') {
2486
+ return;
2487
+ }
2488
+ if (proxySecuritySession.allowLiveTargetDetach) {
2489
+ return;
2490
+ }
2491
+ if (!proxySecuritySession.targetId) {
2492
+ throw new Error(`Proxy security session detached before target identification: ${session.id()}`);
2493
+ }
2494
+ for (let attempt = 0; attempt < 3; attempt += 1) {
2495
+ const generationSessions = [...this.proxySecuritySessions.values()].filter((candidate) => (
2496
+ candidate.generation === generation
2497
+ ));
2498
+ await Promise.allSettled(generationSessions.map((candidate) => candidate.setupPromise));
2499
+ const hasReplacement = [...this.proxySecuritySessions.values()].some((candidate) => (
2500
+ candidate.generation === generation
2501
+ && candidate.targetId === proxySecuritySession.targetId
2502
+ && candidate.fetchEnabled
2503
+ && !candidate.session.detached
2504
+ ));
2505
+ if (hasReplacement) {
2506
+ return;
2507
+ }
2508
+ if (attempt < 2) {
2509
+ await delay(25);
2510
+ }
2511
+ }
2512
+ const browserSecurityCdpSession = this.browserSecurityCdpSession;
2513
+ if (!browserSecurityCdpSession || browserSecurityCdpSession.detached) {
2514
+ throw new Error('Browser security CDP session detached unexpectedly');
2515
+ }
2516
+ const { targetInfos } = await browserSecurityCdpSession.send('Target.getTargets');
2517
+ if (targetInfos.some((targetInfo) => targetInfo.targetId === proxySecuritySession.targetId)) {
2518
+ throw new Error(
2519
+ `Proxy security detached from live target ${proxySecuritySession.targetId}`,
2520
+ );
2521
+ }
2522
+ }
2523
+
2524
+ private removeProxySecuritySession(proxySecuritySession: IProxySecuritySession): void {
2525
+ if (this.proxySecuritySessions.get(proxySecuritySession.session.id()) !== proxySecuritySession) {
2526
+ return;
2527
+ }
2528
+ proxySecuritySession.session.off('Fetch.authRequired', proxySecuritySession.authRequiredListener);
2529
+ proxySecuritySession.session.off('Fetch.requestPaused', proxySecuritySession.requestPausedListener);
2530
+ proxySecuritySession.session.off(
2531
+ 'Network.loadingFinished',
2532
+ proxySecuritySession.loadingFinishedListener,
2533
+ );
2534
+ proxySecuritySession.session.off(
2535
+ 'Network.loadingFailed',
2536
+ proxySecuritySession.loadingFailedListener,
2537
+ );
2538
+ proxySecuritySession.attemptedAuthentications.clear();
2539
+ proxySecuritySession.requestIdsByNetworkId.clear();
2540
+ this.proxySecuritySessions.delete(proxySecuritySession.session.id());
2541
+ }
2542
+
2543
+ private allowOperationalCdpSessionDetach(session: plugins.puppeteer.CDPSession): void {
2544
+ const proxySecuritySession = this.proxySecuritySessions.get(session.id());
2545
+ if (proxySecuritySession) {
2546
+ proxySecuritySession.allowLiveTargetDetach = true;
2547
+ }
2548
+ }
2549
+
2550
+ private trackProxySecurityOperation(
2551
+ operation: Promise<void>,
2552
+ generation: number,
2553
+ errorCode: string,
2554
+ ): void {
2555
+ if (this.proxySecurityOperations.size >= maxProxySecurityOperations) {
2556
+ void operation.catch(() => undefined);
2557
+ this.handleProxySecurityFailure(
2558
+ 'proxy_security_operation_capacity_exceeded',
2559
+ new Error(`Proxy security exceeded ${maxProxySecurityOperations} protocol operations`),
2560
+ generation,
2561
+ );
2562
+ return;
2563
+ }
2564
+ let trackedOperation: Promise<void>;
2565
+ trackedOperation = operation.catch((error) => {
2566
+ this.handleProxySecurityFailure(errorCode, error, generation);
2567
+ }).finally(() => {
2568
+ this.proxySecurityOperations.delete(trackedOperation);
2569
+ });
2570
+ this.proxySecurityOperations.add(trackedOperation);
2571
+ }
2572
+
2573
+ private handleProxySecurityFailure(
2574
+ code: string,
2575
+ error: unknown,
2576
+ generation = this.proxySecurityGeneration,
2577
+ ): void {
2578
+ if (
2579
+ generation !== this.proxySecurityGeneration
2580
+ || this.proxySecurityStopping
2581
+ || this.normalStopRequested
2582
+ || this.status === 'stopped'
2583
+ || this.status === 'stopping'
2584
+ ) {
2585
+ return;
2586
+ }
2587
+ const liveBrowserError: ILiveBrowserError = {
2588
+ code,
2589
+ message: normalizeErrorMessage(error),
2590
+ fatal: true,
2591
+ };
2592
+ this.emitError(liveBrowserError);
2593
+ this.beginShutdown(true);
2594
+ void this.terminate().catch((terminationError) => {
2595
+ this.emitError({
2596
+ code: 'proxy_security_termination_failed',
2597
+ message: normalizeErrorMessage(terminationError),
2598
+ fatal: true,
2599
+ });
2600
+ });
2601
+ }
2602
+
2603
+ private async teardownProxySecurity(): Promise<void> {
2604
+ this.proxySecurityStopping = true;
2605
+ if (this.browserSecurityConnection && this.browserSecuritySessionAttachedListener) {
2606
+ this.browserSecurityConnection.off(
2607
+ 'sessionattached',
2608
+ this.browserSecuritySessionAttachedListener,
2609
+ );
2610
+ }
2611
+ if (this.browserSecurityConnection && this.browserSecuritySessionDetachedListener) {
2612
+ this.browserSecurityConnection.off(
2613
+ 'sessiondetached',
2614
+ this.browserSecuritySessionDetachedListener,
2615
+ );
2616
+ }
2617
+ this.browserSecuritySessionAttachedListener = undefined;
2618
+ this.browserSecuritySessionDetachedListener = undefined;
2619
+ this.browserSecurityConnection = undefined;
2620
+ const ownedSessions = [...this.proxySecuritySessions.values()]
2621
+ .filter((record) => record.ownedByProxySecurity)
2622
+ .map((record) => record.session);
2623
+ for (const proxySecuritySession of [...this.proxySecuritySessions.values()]) {
2624
+ this.removeProxySecuritySession(proxySecuritySession);
2625
+ }
2626
+ for (const ownedSession of ownedSessions) {
2627
+ if (!ownedSession.detached) {
2628
+ try {
2629
+ await ownedSession.detach();
2630
+ } catch {
2631
+ // Browser shutdown can close a target before explicit detach settles.
2632
+ }
2633
+ }
2634
+ }
2635
+ const browserSecurityCdpSession = this.browserSecurityCdpSession;
2636
+ this.browserSecurityCdpSession = undefined;
2637
+ if (browserSecurityCdpSession && !browserSecurityCdpSession.detached) {
2638
+ try {
2639
+ await browserSecurityCdpSession.detach();
2640
+ } catch {
2641
+ // Browser shutdown can detach the browser target first.
2642
+ }
2643
+ }
2644
+ while (this.proxySecurityOperations.size > 0) {
2645
+ await Promise.allSettled([...this.proxySecurityOperations]);
2646
+ }
2647
+ this.proxySecurityGeneration = 0;
2648
+ }
2649
+
1341
2650
  private requireBrowserContext(): plugins.puppeteer.BrowserContext {
1342
2651
  if (this.status !== 'running' || !this.browserContext) {
1343
2652
  throw new Error('LiveBrowserSession is not running');
@@ -1404,6 +2713,68 @@ export class LiveBrowserSession {
1404
2713
  }
1405
2714
  }
1406
2715
 
2716
+ private scheduleDiscoveredPageRegistration(page: plugins.puppeteer.Page): void {
2717
+ if (
2718
+ page.isClosed()
2719
+ || this.tabIdsByPage.has(page)
2720
+ || this.normalStopRequested
2721
+ || this.status === 'stopped'
2722
+ || this.status === 'stopping'
2723
+ ) {
2724
+ return;
2725
+ }
2726
+ const wasScheduled = this.scheduleOperation(async () => {
2727
+ if (page.isClosed() || this.tabIdsByPage.has(page)) {
2728
+ return;
2729
+ }
2730
+ const previousActiveTabId = this.activeTabId;
2731
+ let discoveredTab: IPrivateLiveBrowserTab | undefined;
2732
+ try {
2733
+ discoveredTab = await this.registerPage(page);
2734
+ await this.activateTabInternal(discoveredTab.id);
2735
+ } catch (error) {
2736
+ const rollbackError = await this.rollbackCreatedPage(
2737
+ page,
2738
+ discoveredTab,
2739
+ previousActiveTabId,
2740
+ );
2741
+ if (rollbackError) {
2742
+ const aggregateError = new AggregateError(
2743
+ [error, rollbackError],
2744
+ 'Discovered page registration rollback failed',
2745
+ );
2746
+ this.handleDiscoveredPageFailure(aggregateError);
2747
+ throw aggregateError;
2748
+ }
2749
+ throw error;
2750
+ }
2751
+ }, 'discovered_page_registration_failed', undefined, true);
2752
+ if (!wasScheduled) {
2753
+ this.handleDiscoveredPageFailure(
2754
+ new Error('A discovered page could not be admitted to the internal operation queue'),
2755
+ );
2756
+ }
2757
+ }
2758
+
2759
+ private handleDiscoveredPageFailure(error: unknown): void {
2760
+ if (this.normalStopRequested || this.status === 'stopped' || this.status === 'stopping') {
2761
+ return;
2762
+ }
2763
+ const liveBrowserError: ILiveBrowserError = {
2764
+ code: 'untracked_page_detected',
2765
+ message: normalizeErrorMessage(error),
2766
+ fatal: true,
2767
+ };
2768
+ this.emitError(liveBrowserError);
2769
+ void this.requestShutdown(liveBrowserError).catch((shutdownError) => {
2770
+ this.emitError({
2771
+ code: 'untracked_page_shutdown_failed',
2772
+ message: normalizeErrorMessage(shutdownError),
2773
+ fatal: true,
2774
+ });
2775
+ });
2776
+ }
2777
+
1407
2778
  private async registerPage(page: plugins.puppeteer.Page): Promise<IPrivateLiveBrowserTab> {
1408
2779
  const existingTabId = this.tabIdsByPage.get(page);
1409
2780
  if (existingTabId) {
@@ -1433,6 +2804,18 @@ export class LiveBrowserSession {
1433
2804
  this.tabs.set(tab.id, tab);
1434
2805
  this.tabIdsByPage.set(page, tab.id);
1435
2806
  try {
2807
+ if (this.options.security?.denyFileChoosers) {
2808
+ const securityCdpSession = await page.createCDPSession();
2809
+ this.allowOperationalCdpSessionDetach(securityCdpSession);
2810
+ tab.securityCdpSession = securityCdpSession;
2811
+ await securityCdpSession.send('Page.enable', {
2812
+ enableFileChooserOpenedEvent: true,
2813
+ });
2814
+ await securityCdpSession.send('Page.setInterceptFileChooserDialog', {
2815
+ enabled: true,
2816
+ cancel: true,
2817
+ });
2818
+ }
1436
2819
  await this.ensureTabViewport(tab);
1437
2820
  await this.refreshTab(tab);
1438
2821
 
@@ -1488,6 +2871,7 @@ export class LiveBrowserSession {
1488
2871
  if (frame !== page.mainFrame() || tab.closing) {
1489
2872
  return;
1490
2873
  }
2874
+ tab.evaluationExecutionContextId = undefined;
1491
2875
  const navigationReset = !tab.navigationInProgress;
1492
2876
  if (navigationReset) {
1493
2877
  tab.streamInvalidated = true;
@@ -1563,6 +2947,13 @@ export class LiveBrowserSession {
1563
2947
  );
1564
2948
  return tab;
1565
2949
  } catch (error) {
2950
+ if (tab.securityCdpSession && !tab.securityCdpSession.detached) {
2951
+ try {
2952
+ await tab.securityCdpSession.detach();
2953
+ } catch {
2954
+ // The page may have closed while security setup was failing.
2955
+ }
2956
+ }
1566
2957
  this.removePageListeners(tab);
1567
2958
  this.tabs.delete(tab.id);
1568
2959
  this.tabIdsByPage.delete(page);
@@ -1813,6 +3204,7 @@ export class LiveBrowserSession {
1813
3204
  signal: AbortSignal,
1814
3205
  ): Promise<void> {
1815
3206
  const isActive = this.activeTabId === tab.id;
3207
+ tab.evaluationExecutionContextId = undefined;
1816
3208
  tab.navigationInProgress = true;
1817
3209
  if (isActive) {
1818
3210
  await this.stopScreencast(tab);
@@ -1832,7 +3224,6 @@ export class LiveBrowserSession {
1832
3224
  && this.activeTabId === tab.id
1833
3225
  && this.status === 'running'
1834
3226
  && tab.status === 'open'
1835
- && !signal.aborted
1836
3227
  ) {
1837
3228
  await this.startScreencast(tab);
1838
3229
  }
@@ -1879,6 +3270,7 @@ export class LiveBrowserSession {
1879
3270
  await this.ensureTabViewport(tab);
1880
3271
 
1881
3272
  const cdpSession = await tab.page.createCDPSession();
3273
+ this.allowOperationalCdpSessionDetach(cdpSession);
1882
3274
  const generation = tab.generation + 1;
1883
3275
  const frameListener: TScreencastFrameListener = (event) => {
1884
3276
  this.handleScreencastFrame(tab, cdpSession, generation, event);
@@ -2242,14 +3634,300 @@ export class LiveBrowserSession {
2242
3634
 
2243
3635
  private validateUrl(url: unknown): string {
2244
3636
  const validatedUrl = validateBoundedString(url, 'url', 1, maxUrlLength);
3637
+ let parsedUrl: URL;
2245
3638
  try {
2246
- new URL(validatedUrl);
3639
+ parsedUrl = new URL(validatedUrl);
2247
3640
  } catch {
2248
3641
  throw new Error('url must be absolute');
2249
3642
  }
3643
+ if (
3644
+ this.options.security?.httpNavigationOnly
3645
+ && parsedUrl.protocol !== 'http:'
3646
+ && parsedUrl.protocol !== 'https:'
3647
+ ) {
3648
+ throw new Error('url protocol must be http or https');
3649
+ }
2250
3650
  return validatedUrl;
2251
3651
  }
2252
3652
 
3653
+ private normalizeEvaluationOptions(
3654
+ options: ILiveBrowserEvaluateOptions,
3655
+ ): INormalizedEvaluationOptions {
3656
+ return {
3657
+ timeoutMs: options.timeoutMs === undefined
3658
+ ? 5000
3659
+ : validateInteger(options.timeoutMs, 'timeoutMs', 1, 30000),
3660
+ maxOutputBytes: options.maxOutputBytes === undefined
3661
+ ? 262144
3662
+ : validateInteger(
3663
+ options.maxOutputBytes,
3664
+ 'maxOutputBytes',
3665
+ 1,
3666
+ maxEvaluationOutputBytes,
3667
+ ),
3668
+ maxDepth: options.maxDepth === undefined
3669
+ ? 16
3670
+ : validateInteger(options.maxDepth, 'maxDepth', 1, maxEvaluationDepth),
3671
+ maxNodes: options.maxNodes === undefined
3672
+ ? 10000
3673
+ : validateInteger(options.maxNodes, 'maxNodes', 1, maxEvaluationNodes),
3674
+ maxStringBytes: options.maxStringBytes === undefined
3675
+ ? 65536
3676
+ : validateInteger(
3677
+ options.maxStringBytes,
3678
+ 'maxStringBytes',
3679
+ 0,
3680
+ maxEvaluationStringBytes,
3681
+ ),
3682
+ maxArrayLength: options.maxArrayLength === undefined
3683
+ ? 1000
3684
+ : validateInteger(
3685
+ options.maxArrayLength,
3686
+ 'maxArrayLength',
3687
+ 0,
3688
+ maxEvaluationArrayLength,
3689
+ ),
3690
+ maxObjectKeys: options.maxObjectKeys === undefined
3691
+ ? 1000
3692
+ : validateInteger(
3693
+ options.maxObjectKeys,
3694
+ 'maxObjectKeys',
3695
+ 0,
3696
+ maxEvaluationObjectKeys,
3697
+ ),
3698
+ };
3699
+ }
3700
+
3701
+ private readCdpExceptionMessage(
3702
+ exceptionDetails: plugins.puppeteer.Protocol.Runtime.ExceptionDetails,
3703
+ ): string {
3704
+ const description = exceptionDetails.exception?.description;
3705
+ if (typeof description === 'string' && description.length > 0) {
3706
+ return truncate(description, 2048);
3707
+ }
3708
+ return truncate(exceptionDetails.text, 2048);
3709
+ }
3710
+
3711
+ private createEvaluationBootstrapExpression(): string {
3712
+ const bootstrapKeyLiteral = JSON.stringify(evaluationBootstrapKey);
3713
+ const cancelKeyLiteral = JSON.stringify(evaluationCancelKey);
3714
+ const cleanupKeyLiteral = JSON.stringify(evaluationCleanupKey);
3715
+ return `(() => {
3716
+ 'use strict';
3717
+ if (typeof globalThis[${bootstrapKeyLiteral}] === 'function') {
3718
+ return true;
3719
+ }
3720
+ const safeGlobalThis = globalThis;
3721
+ const SafeArray = Array;
3722
+ const SafeError = Error;
3723
+ const SafeFunction = Function;
3724
+ const SafeJSON = JSON;
3725
+ const SafeMap = Map;
3726
+ const SafeNumber = Number;
3727
+ const SafeObject = Object;
3728
+ const SafePromise = Promise;
3729
+ const SafeString = String;
3730
+ const SafeTextEncoder = TextEncoder;
3731
+ const SafeWeakSet = WeakSet;
3732
+ const SafeClearTimeout = clearTimeout;
3733
+ const SafeSetTimeout = setTimeout;
3734
+ const safeCreate = SafeObject.create;
3735
+ const safeDefineProperty = SafeObject.defineProperty;
3736
+ const safeGetOwnPropertyDescriptor = SafeObject.getOwnPropertyDescriptor;
3737
+ const safeGetOwnPropertySymbols = SafeObject.getOwnPropertySymbols;
3738
+ const safeGetPrototypeOf = SafeObject.getPrototypeOf;
3739
+ const safeIsArray = SafeArray.isArray;
3740
+ const safeIsFinite = SafeNumber.isFinite;
3741
+ const safeKeys = SafeObject.keys;
3742
+ const safeSetPrototypeOf = SafeObject.setPrototypeOf;
3743
+ const safeStringify = SafeJSON.stringify;
3744
+ const safeMapDelete = SafeFunction.prototype.call.bind(SafeMap.prototype.delete);
3745
+ const safeMapGet = SafeFunction.prototype.call.bind(SafeMap.prototype.get);
3746
+ const safeMapSet = SafeFunction.prototype.call.bind(SafeMap.prototype.set);
3747
+ const safePromiseThen = SafeFunction.prototype.call.bind(SafePromise.prototype.then);
3748
+ const safeWeakSetAdd = SafeFunction.prototype.call.bind(SafeWeakSet.prototype.add);
3749
+ const safeWeakSetHas = SafeFunction.prototype.call.bind(SafeWeakSet.prototype.has);
3750
+ const safeEncode = SafeFunction.prototype.call.bind(SafeTextEncoder.prototype.encode);
3751
+ const encoder = new SafeTextEncoder();
3752
+ const cancellationHandlers = new SafeMap();
3753
+
3754
+ const createEnvelope = (ok, value) => {
3755
+ const envelope = safeCreate(null);
3756
+ envelope.ok = ok;
3757
+ if (ok) {
3758
+ envelope.json = value;
3759
+ } else {
3760
+ envelope.error = value;
3761
+ }
3762
+ return envelope;
3763
+ };
3764
+ const byteLength = (value) => safeEncode(encoder, value).byteLength;
3765
+ const cancelEvaluation = (cancellationKey) => {
3766
+ const handler = safeMapGet(cancellationHandlers, cancellationKey);
3767
+ if (typeof handler === 'function') {
3768
+ handler();
3769
+ }
3770
+ return safeMapDelete(cancellationHandlers, cancellationKey);
3771
+ };
3772
+ const evaluate = async (source, options, cancellationKey) => {
3773
+ const seen = new SafeWeakSet();
3774
+ let visitedNodes = 0;
3775
+ const normalize = (value, depth) => {
3776
+ visitedNodes += 1;
3777
+ if (visitedNodes > options.maxNodes) {
3778
+ throw new SafeError('Evaluation result exceeded maxNodes');
3779
+ }
3780
+ if (depth > options.maxDepth) {
3781
+ throw new SafeError('Evaluation result exceeded maxDepth');
3782
+ }
3783
+ if (value === null || typeof value === 'boolean') {
3784
+ return value;
3785
+ }
3786
+ if (typeof value === 'number') {
3787
+ if (!safeIsFinite(value)) {
3788
+ throw new SafeError('Evaluation result contains a non-finite number');
3789
+ }
3790
+ return value;
3791
+ }
3792
+ if (typeof value === 'string') {
3793
+ if (byteLength(value) > options.maxStringBytes) {
3794
+ throw new SafeError('Evaluation result string exceeded maxStringBytes');
3795
+ }
3796
+ return value;
3797
+ }
3798
+ if (typeof value !== 'object') {
3799
+ throw new SafeError('Evaluation result contains a non-JSON value');
3800
+ }
3801
+ if (safeWeakSetHas(seen, value)) {
3802
+ throw new SafeError('Evaluation result contains a cycle or repeated object');
3803
+ }
3804
+ safeWeakSetAdd(seen, value);
3805
+
3806
+ if (safeIsArray(value)) {
3807
+ if (value.length > options.maxArrayLength) {
3808
+ throw new SafeError('Evaluation result array exceeded maxArrayLength');
3809
+ }
3810
+ const keys = safeKeys(value);
3811
+ if (keys.length !== value.length) {
3812
+ throw new SafeError('Evaluation result contains a sparse or extended array');
3813
+ }
3814
+ const output = SafeArray(value.length);
3815
+ safeSetPrototypeOf(output, null);
3816
+ for (let index = 0; index < value.length; index += 1) {
3817
+ const descriptor = safeGetOwnPropertyDescriptor(value, SafeString(index));
3818
+ if (!descriptor || !('value' in descriptor)) {
3819
+ throw new SafeError('Evaluation result contains an array accessor');
3820
+ }
3821
+ output[index] = normalize(descriptor.value, depth + 1);
3822
+ }
3823
+ return output;
3824
+ }
3825
+
3826
+ const prototype = safeGetPrototypeOf(value);
3827
+ if (prototype !== SafeObject.prototype && prototype !== null) {
3828
+ throw new SafeError('Evaluation result contains a non-plain object');
3829
+ }
3830
+ if (safeGetOwnPropertySymbols(value).length > 0) {
3831
+ throw new SafeError('Evaluation result contains symbol properties');
3832
+ }
3833
+ const keys = safeKeys(value);
3834
+ if (keys.length > options.maxObjectKeys) {
3835
+ throw new SafeError('Evaluation result object exceeded maxObjectKeys');
3836
+ }
3837
+ const output = safeCreate(null);
3838
+ for (let index = 0; index < keys.length; index += 1) {
3839
+ const key = keys[index];
3840
+ if (byteLength(key) > options.maxStringBytes) {
3841
+ throw new SafeError('Evaluation result key exceeded maxStringBytes');
3842
+ }
3843
+ const descriptor = safeGetOwnPropertyDescriptor(value, key);
3844
+ if (!descriptor || !('value' in descriptor)) {
3845
+ throw new SafeError('Evaluation result contains an object accessor');
3846
+ }
3847
+ output[key] = normalize(descriptor.value, depth + 1);
3848
+ }
3849
+ return output;
3850
+ };
3851
+
3852
+ try {
3853
+ const execute = SafeFunction(
3854
+ '\"use strict\"; return (async () => (' + source + '\\n))();',
3855
+ );
3856
+ const boundedExecution = new SafePromise((resolve, reject) => {
3857
+ let timeout;
3858
+ safeMapSet(cancellationHandlers, cancellationKey, () => {
3859
+ if (timeout !== undefined) {
3860
+ SafeClearTimeout(timeout);
3861
+ }
3862
+ reject(new SafeError('Evaluation cancelled'));
3863
+ });
3864
+ timeout = SafeSetTimeout(() => {
3865
+ reject(new SafeError('Evaluation timed out after ' + options.timeoutMs + 'ms'));
3866
+ }, options.timeoutMs);
3867
+ safePromiseThen(
3868
+ execute(),
3869
+ (value) => {
3870
+ SafeClearTimeout(timeout);
3871
+ resolve(value);
3872
+ },
3873
+ (error) => {
3874
+ SafeClearTimeout(timeout);
3875
+ reject(error);
3876
+ },
3877
+ );
3878
+ });
3879
+ let normalizedResult;
3880
+ try {
3881
+ normalizedResult = normalize(await boundedExecution, 0);
3882
+ } finally {
3883
+ safeMapDelete(cancellationHandlers, cancellationKey);
3884
+ }
3885
+ const json = safeStringify(normalizedResult);
3886
+ if (typeof json !== 'string' || byteLength(json) > options.maxOutputBytes) {
3887
+ throw new SafeError('Evaluation result exceeded maxOutputBytes');
3888
+ }
3889
+ return createEnvelope(true, json);
3890
+ } catch (error) {
3891
+ let message = 'Evaluation failed';
3892
+ if (typeof error === 'string') {
3893
+ message = error;
3894
+ } else if (error && typeof error === 'object') {
3895
+ const descriptor = safeGetOwnPropertyDescriptor(error, 'message');
3896
+ if (descriptor && 'value' in descriptor && typeof descriptor.value === 'string') {
3897
+ message = descriptor.value;
3898
+ }
3899
+ }
3900
+ if (byteLength(message) > 2048) {
3901
+ message = 'Evaluation failed with an oversized error';
3902
+ }
3903
+ return createEnvelope(false, message);
3904
+ }
3905
+ };
3906
+
3907
+ for (const [key, value] of [
3908
+ [${bootstrapKeyLiteral}, evaluate],
3909
+ [${cancelKeyLiteral}, cancelEvaluation],
3910
+ [${cleanupKeyLiteral}, cancelEvaluation],
3911
+ ]) {
3912
+ safeDefineProperty(safeGlobalThis, key, {
3913
+ value,
3914
+ configurable: false,
3915
+ enumerable: false,
3916
+ writable: false,
3917
+ });
3918
+ }
3919
+ return true;
3920
+ })()`;
3921
+ }
3922
+
3923
+ private createEvaluationExpression(
3924
+ expression: string,
3925
+ options: INormalizedEvaluationOptions,
3926
+ cancellationKey: string,
3927
+ ): string {
3928
+ return `globalThis[${JSON.stringify(evaluationBootstrapKey)}](${JSON.stringify(expression)}, ${JSON.stringify(options)}, ${JSON.stringify(cancellationKey)})`;
3929
+ }
3930
+
2253
3931
  private validateTimeout(timeoutMs?: number, defaultValue = 5000): number {
2254
3932
  if (timeoutMs === undefined) {
2255
3933
  return defaultValue;