@push.rocks/smartpuppeteer 2.2.0 → 2.4.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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/smartpuppeteer.classes.livebrowsersession.d.ts +35 -1
- package/dist_ts/smartpuppeteer.classes.livebrowsersession.js +878 -38
- package/dist_ts/smartpuppeteer.helpers.process.d.ts +14 -0
- package/dist_ts/smartpuppeteer.helpers.process.js +132 -0
- package/dist_ts/smartpuppeteer.interfaces.livebrowser.d.ts +26 -0
- package/dist_ts/smartpuppeteer.interfaces.livebrowser.js +3 -2
- package/dist_ts/smartpuppeteer.plugins.d.ts +4 -2
- package/dist_ts/smartpuppeteer.plugins.js +5 -3
- package/package.json +3 -3
- package/readme.hints.md +1 -1
- package/readme.md +20 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/smartpuppeteer.classes.livebrowsersession.ts +1120 -44
- package/ts/smartpuppeteer.helpers.process.ts +169 -0
- package/ts/smartpuppeteer.interfaces.livebrowser.ts +31 -0
- package/ts/smartpuppeteer.plugins.ts +4 -2
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { getEnvAwareBrowserInstance } from './smartpuppeteer.classes.smartpuppeteer.js';
|
|
2
|
+
import {
|
|
3
|
+
liveBrowserDefaultMaxOutstandingFrames,
|
|
4
|
+
liveBrowserMaxOutstandingFrames,
|
|
5
|
+
} from './smartpuppeteer.interfaces.livebrowser.js';
|
|
6
|
+
import {
|
|
7
|
+
delay,
|
|
8
|
+
type IOwnedProcessIdentity,
|
|
9
|
+
killFrozenOwnedProcessGroup,
|
|
10
|
+
listOwnedProcessGroupMembers,
|
|
11
|
+
readOwnedProcessIdentity,
|
|
12
|
+
signalOwnedProcessIdentity,
|
|
13
|
+
signalOwnedProcessGroup,
|
|
14
|
+
} from './smartpuppeteer.helpers.process.js';
|
|
2
15
|
import type {
|
|
3
16
|
ILiveBrowserClickOptions,
|
|
4
17
|
ILiveBrowserCreateTabOptions,
|
|
@@ -18,11 +31,14 @@ import type {
|
|
|
18
31
|
ILiveBrowserObserveOptions,
|
|
19
32
|
ILiveBrowserOperationOptions,
|
|
20
33
|
ILiveBrowserPressOptions,
|
|
34
|
+
ILiveBrowserProcessState,
|
|
21
35
|
ILiveBrowserSessionOptions,
|
|
22
36
|
ILiveBrowserSnapshot,
|
|
23
37
|
ILiveBrowserSnapshotOptions,
|
|
24
38
|
ILiveBrowserState,
|
|
25
39
|
ILiveBrowserTabState,
|
|
40
|
+
ILiveBrowserTerminationOptions,
|
|
41
|
+
ILiveBrowserTerminationResult,
|
|
26
42
|
ILiveBrowserViewport,
|
|
27
43
|
ILiveBrowserWheelInput,
|
|
28
44
|
TLiveBrowserEvent,
|
|
@@ -47,7 +63,7 @@ const maxSelectorLength = 4096;
|
|
|
47
63
|
const maxTextLength = 32768;
|
|
48
64
|
const maxUrlLength = 16384;
|
|
49
65
|
const maxTimeoutMs = 60000;
|
|
50
|
-
const
|
|
66
|
+
const frameAcknowledgementTimeoutMs = 5000;
|
|
51
67
|
const maxQueuedPublicOperations = 64;
|
|
52
68
|
const maxQueuedInternalOperations = 128;
|
|
53
69
|
const maxEvaluationScriptBytes = 262144;
|
|
@@ -60,10 +76,52 @@ const maxEvaluationObjectKeys = 10000;
|
|
|
60
76
|
const evaluationBootstrapKey = '__smartpuppeteerEvaluate';
|
|
61
77
|
const evaluationCancelKey = '__smartpuppeteerCancel';
|
|
62
78
|
const evaluationCleanupKey = '__smartpuppeteerCleanup';
|
|
79
|
+
const maxProxySecuritySessions = 256;
|
|
80
|
+
const maxTrackedProxyRequests = 1024;
|
|
81
|
+
const maxTotalTrackedProxyRequests = 4096;
|
|
82
|
+
const maxProxySecurityOperations = 2048;
|
|
83
|
+
const proxySeedTargetTypes = new Set([
|
|
84
|
+
'background_page',
|
|
85
|
+
'page',
|
|
86
|
+
'service_worker',
|
|
87
|
+
'shared_worker',
|
|
88
|
+
'webview',
|
|
89
|
+
]);
|
|
90
|
+
const proxyFetchUnsupportedTargetTypes = new Set(['tab', 'worker']);
|
|
63
91
|
|
|
64
92
|
type TScreencastFrameEvent = plugins.puppeteer.Protocol.Page.ScreencastFrameEvent;
|
|
65
93
|
type TScreencastFrameListener = (event: TScreencastFrameEvent) => void;
|
|
66
94
|
type TCdpSessionDetachedListener = (session: plugins.puppeteer.CDPSession) => void;
|
|
95
|
+
type TOwnedChildProcess = NonNullable<ReturnType<plugins.puppeteer.Browser['process']>>;
|
|
96
|
+
type TProxyAuthRequiredEvent = plugins.puppeteer.Protocol.Fetch.AuthRequiredEvent;
|
|
97
|
+
type TProxyRequestPausedEvent = plugins.puppeteer.Protocol.Fetch.RequestPausedEvent;
|
|
98
|
+
type TNetworkLoadingFinishedEvent = plugins.puppeteer.Protocol.Network.LoadingFinishedEvent;
|
|
99
|
+
type TNetworkLoadingFailedEvent = plugins.puppeteer.Protocol.Network.LoadingFailedEvent;
|
|
100
|
+
|
|
101
|
+
interface IProxySecuritySession {
|
|
102
|
+
session: plugins.puppeteer.CDPSession;
|
|
103
|
+
generation: number;
|
|
104
|
+
targetId?: string;
|
|
105
|
+
targetType?: string;
|
|
106
|
+
fetchEnabled: boolean;
|
|
107
|
+
allowLiveTargetDetach: boolean;
|
|
108
|
+
ownedByProxySecurity: boolean;
|
|
109
|
+
attemptedAuthentications: Set<string>;
|
|
110
|
+
requestIdsByNetworkId: Map<string, string>;
|
|
111
|
+
authRequiredListener: (event: TProxyAuthRequiredEvent) => void;
|
|
112
|
+
requestPausedListener: (event: TProxyRequestPausedEvent) => void;
|
|
113
|
+
loadingFinishedListener: (event: TNetworkLoadingFinishedEvent) => void;
|
|
114
|
+
loadingFailedListener: (event: TNetworkLoadingFailedEvent) => void;
|
|
115
|
+
setupPromise: Promise<void>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface IOwnedBrowserProcess {
|
|
119
|
+
generation: number;
|
|
120
|
+
childProcess: TOwnedChildProcess;
|
|
121
|
+
identity?: IOwnedProcessIdentity;
|
|
122
|
+
exitPromise: Promise<void>;
|
|
123
|
+
forceSignalled: boolean;
|
|
124
|
+
}
|
|
67
125
|
|
|
68
126
|
interface IPrivateLiveBrowserTab {
|
|
69
127
|
id: string;
|
|
@@ -89,12 +147,17 @@ interface IPrivateLiveBrowserTab {
|
|
|
89
147
|
removeListeners: Array<() => void>;
|
|
90
148
|
}
|
|
91
149
|
|
|
92
|
-
interface
|
|
150
|
+
interface ICdpScreencastFrame {
|
|
93
151
|
tabId: string;
|
|
94
152
|
generation: number;
|
|
95
153
|
viewportRevision: number;
|
|
96
154
|
cdpSessionId: number;
|
|
97
155
|
cdpSession: plugins.puppeteer.CDPSession;
|
|
156
|
+
acknowledgementPromise?: Promise<boolean>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface IOutstandingFrame extends ICdpScreencastFrame {
|
|
160
|
+
sequence: number;
|
|
98
161
|
}
|
|
99
162
|
|
|
100
163
|
interface IImageDimensions {
|
|
@@ -313,17 +376,34 @@ export class LiveBrowserSession {
|
|
|
313
376
|
private readonly tabs = new Map<string, IPrivateLiveBrowserTab>();
|
|
314
377
|
private readonly tabIdsByPage = new WeakMap<plugins.puppeteer.Page, string>();
|
|
315
378
|
private readonly outstandingFrames = new Map<number, IOutstandingFrame>();
|
|
379
|
+
private readonly cdpFramesBeingAcknowledged = new Set<ICdpScreencastFrame>();
|
|
380
|
+
private readonly maxOutstandingFrames: number;
|
|
316
381
|
|
|
317
382
|
private browser?: plugins.puppeteer.Browser;
|
|
318
383
|
private browserContext?: plugins.puppeteer.BrowserContext;
|
|
319
384
|
private browserLifetimeController?: AbortController;
|
|
320
385
|
private browserDisconnectedListener?: () => void;
|
|
386
|
+
private browserTargetCreatedListener?: (target: plugins.puppeteer.Target) => void;
|
|
387
|
+
private browserSecurityCdpSession?: plugins.puppeteer.CDPSession;
|
|
388
|
+
private browserSecurityConnection?: plugins.puppeteer.Connection;
|
|
389
|
+
private browserSecuritySessionAttachedListener?: (session: plugins.puppeteer.CDPSession) => void;
|
|
390
|
+
private browserSecuritySessionDetachedListener?: TCdpSessionDetachedListener;
|
|
391
|
+
private readonly proxySecuritySessions = new Map<string, IProxySecuritySession>();
|
|
392
|
+
private readonly proxySecurityOperations = new Set<Promise<void>>();
|
|
393
|
+
private proxySecurityGeneration = 0;
|
|
394
|
+
private proxySecurityStopping = false;
|
|
395
|
+
private ownedBrowserProcess?: IOwnedBrowserProcess;
|
|
396
|
+
private processGeneration = 0;
|
|
397
|
+
private startRequestCount = 0;
|
|
398
|
+
private terminationSettled = true;
|
|
399
|
+
private terminationPromise?: Promise<ILiveBrowserTerminationResult>;
|
|
321
400
|
private readonly operationQueue: IQueuedOperation[] = [];
|
|
322
401
|
private activeOperation?: IQueuedOperation;
|
|
323
402
|
private operationRunning = false;
|
|
324
403
|
private admittedPublicOperations = 0;
|
|
325
404
|
private admittedInternalOperations = 0;
|
|
326
405
|
private shutdownPromise?: Promise<void>;
|
|
406
|
+
private shutdownFrameDrainPromise?: Promise<void>;
|
|
327
407
|
private status: 'stopped' | 'starting' | 'running' | 'stopping' = 'stopped';
|
|
328
408
|
private activeTabId: string | null = null;
|
|
329
409
|
private viewport: ILiveBrowserViewport = { ...defaultViewport };
|
|
@@ -350,9 +430,42 @@ export class LiveBrowserSession {
|
|
|
350
430
|
if (optionsArg.launchOptions && 'signal' in optionsArg.launchOptions) {
|
|
351
431
|
throw new Error('LiveBrowserSession owns launch cancellation; launchOptions.signal is unsupported');
|
|
352
432
|
}
|
|
433
|
+
if (
|
|
434
|
+
optionsArg.screencast !== undefined
|
|
435
|
+
&& (
|
|
436
|
+
!optionsArg.screencast
|
|
437
|
+
|| typeof optionsArg.screencast !== 'object'
|
|
438
|
+
|| Array.isArray(optionsArg.screencast)
|
|
439
|
+
)
|
|
440
|
+
) {
|
|
441
|
+
throw new Error('screencast must be an object');
|
|
442
|
+
}
|
|
353
443
|
validateOptionalBoolean(optionsArg.allowEvaluation, 'allowEvaluation');
|
|
354
|
-
for (const
|
|
355
|
-
|
|
444
|
+
for (const name of [
|
|
445
|
+
'denyDownloads',
|
|
446
|
+
'denyFileChoosers',
|
|
447
|
+
'denyPermissions',
|
|
448
|
+
'httpNavigationOnly',
|
|
449
|
+
] as const) {
|
|
450
|
+
validateOptionalBoolean(optionsArg.security?.[name], `security.${name}`);
|
|
451
|
+
}
|
|
452
|
+
const proxyCredentials = optionsArg.security?.proxyCredentials;
|
|
453
|
+
if (proxyCredentials !== undefined) {
|
|
454
|
+
if (!proxyCredentials || typeof proxyCredentials !== 'object') {
|
|
455
|
+
throw new Error('security.proxyCredentials must be an object');
|
|
456
|
+
}
|
|
457
|
+
validateBoundedString(
|
|
458
|
+
proxyCredentials.username,
|
|
459
|
+
'security.proxyCredentials.username',
|
|
460
|
+
1,
|
|
461
|
+
256,
|
|
462
|
+
);
|
|
463
|
+
validateBoundedString(
|
|
464
|
+
proxyCredentials.password,
|
|
465
|
+
'security.proxyCredentials.password',
|
|
466
|
+
1,
|
|
467
|
+
1024,
|
|
468
|
+
);
|
|
356
469
|
}
|
|
357
470
|
const launchViewport = optionsArg.launchOptions?.defaultViewport;
|
|
358
471
|
const viewport = normalizeViewport(
|
|
@@ -377,10 +490,17 @@ export class LiveBrowserSession {
|
|
|
377
490
|
},
|
|
378
491
|
viewport,
|
|
379
492
|
screencast: optionsArg.screencast ? { ...optionsArg.screencast } : undefined,
|
|
380
|
-
security: optionsArg.security
|
|
493
|
+
security: optionsArg.security
|
|
494
|
+
? {
|
|
495
|
+
...optionsArg.security,
|
|
496
|
+
proxyCredentials: proxyCredentials ? { ...proxyCredentials } : undefined,
|
|
497
|
+
}
|
|
498
|
+
: undefined,
|
|
381
499
|
};
|
|
382
500
|
this.viewport = { ...viewport };
|
|
383
501
|
this.validateScreencastOptions();
|
|
502
|
+
this.maxOutstandingFrames = this.options.screencast?.maxOutstandingFrames
|
|
503
|
+
?? liveBrowserDefaultMaxOutstandingFrames;
|
|
384
504
|
}
|
|
385
505
|
|
|
386
506
|
public onEvent(listener: TLiveBrowserEventListener): () => void {
|
|
@@ -404,12 +524,86 @@ export class LiveBrowserSession {
|
|
|
404
524
|
};
|
|
405
525
|
}
|
|
406
526
|
|
|
407
|
-
public
|
|
408
|
-
|
|
527
|
+
public getProcessState(): ILiveBrowserProcessState {
|
|
528
|
+
const ownedProcess = this.ownedBrowserProcess;
|
|
529
|
+
if (!ownedProcess) {
|
|
530
|
+
return {
|
|
531
|
+
generation: this.processGeneration,
|
|
532
|
+
pid: null,
|
|
533
|
+
processGroupId: null,
|
|
534
|
+
running: false,
|
|
535
|
+
exitCode: null,
|
|
536
|
+
signalCode: null,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
const childProcess = ownedProcess.childProcess;
|
|
540
|
+
return {
|
|
541
|
+
generation: ownedProcess.generation,
|
|
542
|
+
pid: childProcess.pid ?? null,
|
|
543
|
+
processGroupId: ownedProcess.identity?.processGroupId ?? null,
|
|
544
|
+
running: childProcess.exitCode === null && childProcess.signalCode === null,
|
|
545
|
+
exitCode: childProcess.exitCode,
|
|
546
|
+
signalCode: childProcess.signalCode,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
public terminate(
|
|
551
|
+
optionsArg: ILiveBrowserTerminationOptions = {},
|
|
552
|
+
): Promise<ILiveBrowserTerminationResult> {
|
|
553
|
+
if (this.terminationPromise) {
|
|
554
|
+
return this.terminationPromise;
|
|
555
|
+
}
|
|
556
|
+
const gracefulTimeoutMs = optionsArg.gracefulTimeoutMs === undefined
|
|
557
|
+
? 5000
|
|
558
|
+
: validateInteger(optionsArg.gracefulTimeoutMs, 'gracefulTimeoutMs', 1, 60000);
|
|
559
|
+
const forceTimeoutMs = optionsArg.forceTimeoutMs === undefined
|
|
560
|
+
? 5000
|
|
561
|
+
: validateInteger(optionsArg.forceTimeoutMs, 'forceTimeoutMs', 1, 60000);
|
|
562
|
+
let resolveTermination!: (result: ILiveBrowserTerminationResult) => void;
|
|
563
|
+
let rejectTermination!: (error: unknown) => void;
|
|
564
|
+
const terminationPromise = new Promise<ILiveBrowserTerminationResult>((resolve, reject) => {
|
|
565
|
+
resolveTermination = resolve;
|
|
566
|
+
rejectTermination = reject;
|
|
567
|
+
});
|
|
568
|
+
this.terminationPromise = terminationPromise;
|
|
569
|
+
this.terminationSettled = false;
|
|
570
|
+
void this.terminateInternal(gracefulTimeoutMs, forceTimeoutMs).then(
|
|
571
|
+
(result) => {
|
|
572
|
+
this.terminationSettled = true;
|
|
573
|
+
resolveTermination(result);
|
|
574
|
+
},
|
|
575
|
+
(error) => {
|
|
576
|
+
if (this.terminationPromise === terminationPromise) {
|
|
577
|
+
this.terminationPromise = undefined;
|
|
578
|
+
}
|
|
579
|
+
this.terminationSettled = true;
|
|
580
|
+
rejectTermination(error);
|
|
581
|
+
},
|
|
582
|
+
);
|
|
583
|
+
return terminationPromise;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
public start(operationOptions: ILiveBrowserOperationOptions = {}): Promise<void> {
|
|
587
|
+
if (!this.terminationSettled) {
|
|
588
|
+
return Promise.reject(new Error('A browser termination is still in progress'));
|
|
589
|
+
}
|
|
590
|
+
if (!this.ownedBrowserProcess && this.status === 'stopped') {
|
|
591
|
+
this.terminationPromise = undefined;
|
|
592
|
+
}
|
|
593
|
+
this.startRequestCount += 1;
|
|
594
|
+
const startPromise = this.enqueuePublicOperation(async (signal) => {
|
|
409
595
|
if (this.status === 'running' || this.status === 'starting') {
|
|
410
596
|
return;
|
|
411
597
|
}
|
|
412
598
|
|
|
599
|
+
await this.releaseExitedOwnedProcess();
|
|
600
|
+
if (signal.aborted) {
|
|
601
|
+
throw signal.reason;
|
|
602
|
+
}
|
|
603
|
+
if (this.ownedBrowserProcess) {
|
|
604
|
+
throw new Error('A previous owned Chromium process has not been confirmed dead');
|
|
605
|
+
}
|
|
606
|
+
|
|
413
607
|
this.normalStopRequested = false;
|
|
414
608
|
this.lastError = undefined;
|
|
415
609
|
this.viewportRevision = 1;
|
|
@@ -445,10 +639,56 @@ export class LiveBrowserSession {
|
|
|
445
639
|
} finally {
|
|
446
640
|
signal.removeEventListener('abort', abortBrowserLaunch);
|
|
447
641
|
}
|
|
642
|
+
const childProcess = this.browser.process();
|
|
643
|
+
if (!childProcess?.pid) {
|
|
644
|
+
throw new Error('LiveBrowserSession did not receive an owned Chromium process');
|
|
645
|
+
}
|
|
646
|
+
const exitPromise = new Promise<void>((resolve) => {
|
|
647
|
+
if (childProcess.exitCode !== null || childProcess.signalCode !== null) {
|
|
648
|
+
resolve();
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
childProcess.once('exit', () => resolve());
|
|
652
|
+
});
|
|
653
|
+
this.processGeneration += 1;
|
|
654
|
+
this.ownedBrowserProcess = {
|
|
655
|
+
generation: this.processGeneration,
|
|
656
|
+
childProcess,
|
|
657
|
+
exitPromise,
|
|
658
|
+
forceSignalled: false,
|
|
659
|
+
};
|
|
660
|
+
const identity = process.platform === 'linux'
|
|
661
|
+
? await readOwnedProcessIdentity(childProcess.pid)
|
|
662
|
+
: undefined;
|
|
663
|
+
if (process.platform === 'linux' && !identity) {
|
|
664
|
+
throw new Error('The owned Chromium process exited before its identity was captured');
|
|
665
|
+
}
|
|
666
|
+
if (
|
|
667
|
+
identity
|
|
668
|
+
&& (identity.processGroupId !== childProcess.pid || identity.sessionId !== childProcess.pid)
|
|
669
|
+
) {
|
|
670
|
+
throw new Error('Chromium was not launched as a dedicated process-group leader');
|
|
671
|
+
}
|
|
672
|
+
this.ownedBrowserProcess.identity = identity;
|
|
448
673
|
if (signal.aborted) {
|
|
449
674
|
throw signal.reason;
|
|
450
675
|
}
|
|
451
676
|
this.browserContext = this.browser.defaultBrowserContext();
|
|
677
|
+
this.browserTargetCreatedListener = (target) => {
|
|
678
|
+
if (target.type() !== 'page' || target.browserContext() !== this.browserContext) {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
void target.page().then((page) => {
|
|
682
|
+
if (page) {
|
|
683
|
+
this.scheduleDiscoveredPageRegistration(page);
|
|
684
|
+
}
|
|
685
|
+
}).catch((error) => {
|
|
686
|
+
if (!this.normalStopRequested && this.status !== 'stopped' && this.status !== 'stopping') {
|
|
687
|
+
this.handleDiscoveredPageFailure(error);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
};
|
|
691
|
+
this.browser.on('targetcreated', this.browserTargetCreatedListener);
|
|
452
692
|
await this.configureBrowserSecurity(this.browser);
|
|
453
693
|
this.browserDisconnectedListener = () => {
|
|
454
694
|
if (this.normalStopRequested || this.status === 'stopped' || this.status === 'stopping') {
|
|
@@ -497,7 +737,14 @@ export class LiveBrowserSession {
|
|
|
497
737
|
}
|
|
498
738
|
} catch (error) {
|
|
499
739
|
if (signal.aborted || this.normalStopRequested) {
|
|
500
|
-
|
|
740
|
+
try {
|
|
741
|
+
await this.stopInternal();
|
|
742
|
+
} catch (cleanupError) {
|
|
743
|
+
throw new AggregateError(
|
|
744
|
+
[error, cleanupError],
|
|
745
|
+
'Cancelled browser startup cleanup was incomplete',
|
|
746
|
+
);
|
|
747
|
+
}
|
|
501
748
|
throw error;
|
|
502
749
|
}
|
|
503
750
|
const startError: ILiveBrowserError = {
|
|
@@ -506,10 +753,20 @@ export class LiveBrowserSession {
|
|
|
506
753
|
fatal: true,
|
|
507
754
|
};
|
|
508
755
|
this.emitError(startError);
|
|
509
|
-
|
|
756
|
+
try {
|
|
757
|
+
await this.stopInternal(startError);
|
|
758
|
+
} catch (cleanupError) {
|
|
759
|
+
throw new AggregateError(
|
|
760
|
+
[error, cleanupError],
|
|
761
|
+
'Browser startup failed and cleanup was incomplete',
|
|
762
|
+
);
|
|
763
|
+
}
|
|
510
764
|
throw error;
|
|
511
765
|
}
|
|
512
766
|
}, operationOptions);
|
|
767
|
+
return startPromise.finally(() => {
|
|
768
|
+
this.startRequestCount -= 1;
|
|
769
|
+
});
|
|
513
770
|
}
|
|
514
771
|
|
|
515
772
|
public async stop(): Promise<void> {
|
|
@@ -1070,6 +1327,7 @@ export class LiveBrowserSession {
|
|
|
1070
1327
|
return this.enqueuePublicOperation(async (signal) => {
|
|
1071
1328
|
const tab = this.resolveActionTab(optionsArg.tabId);
|
|
1072
1329
|
const cdpSession = await tab.page.createCDPSession();
|
|
1330
|
+
this.allowOperationalCdpSessionDetach(cdpSession);
|
|
1073
1331
|
let executionContextId: number | undefined;
|
|
1074
1332
|
let cancellationPromise: Promise<void> | undefined;
|
|
1075
1333
|
let terminationPromise: Promise<void> | undefined;
|
|
@@ -1346,7 +1604,25 @@ export class LiveBrowserSession {
|
|
|
1346
1604
|
return Promise.reject(new Error('LiveBrowserSession operation queue is full'));
|
|
1347
1605
|
}
|
|
1348
1606
|
this.admittedPublicOperations += 1;
|
|
1349
|
-
return this.enqueueQueuedOperation('public',
|
|
1607
|
+
return this.enqueueQueuedOperation('public', async (signal) => {
|
|
1608
|
+
this.assertProxySecurityReady();
|
|
1609
|
+
return operation(signal);
|
|
1610
|
+
}, false, callerSignal);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
private assertProxySecurityReady(): void {
|
|
1614
|
+
if (!this.options.security?.proxyCredentials || this.status !== 'running') {
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
if (
|
|
1618
|
+
!this.browserSecurityConnection
|
|
1619
|
+
|| !this.browserSecurityCdpSession
|
|
1620
|
+
|| this.browserSecurityCdpSession.detached
|
|
1621
|
+
) {
|
|
1622
|
+
const error = new Error('Authenticated proxy security is not attached');
|
|
1623
|
+
this.handleProxySecurityFailure('proxy_security_unavailable', error);
|
|
1624
|
+
throw error;
|
|
1625
|
+
}
|
|
1350
1626
|
}
|
|
1351
1627
|
|
|
1352
1628
|
private enqueueInternalOperation(
|
|
@@ -1495,21 +1771,34 @@ export class LiveBrowserSession {
|
|
|
1495
1771
|
this.emitState();
|
|
1496
1772
|
}
|
|
1497
1773
|
const shutdownError = new Error('LiveBrowserSession is stopping');
|
|
1498
|
-
|
|
1774
|
+
const activeOperation = this.activeOperation;
|
|
1775
|
+
const shouldAbortActiveOperation = Boolean(
|
|
1499
1776
|
abortActiveOperation
|
|
1500
|
-
&&
|
|
1501
|
-
&&
|
|
1502
|
-
)
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1777
|
+
&& activeOperation
|
|
1778
|
+
&& activeOperation.kind !== 'shutdown'
|
|
1779
|
+
);
|
|
1780
|
+
if (shouldAbortActiveOperation && activeOperation) {
|
|
1781
|
+
const browserLifetimeController = this.browserLifetimeController;
|
|
1782
|
+
const frameDrainPromise = (async () => {
|
|
1783
|
+
try {
|
|
1784
|
+
await this.retireOutstandingFrames(() => true);
|
|
1785
|
+
await this.waitForCdpFrameAcknowledgements();
|
|
1786
|
+
} finally {
|
|
1787
|
+
if (browserLifetimeController && !browserLifetimeController.signal.aborted) {
|
|
1788
|
+
browserLifetimeController.abort(shutdownError);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
})();
|
|
1792
|
+
this.shutdownFrameDrainPromise = frameDrainPromise;
|
|
1793
|
+
void frameDrainPromise.catch(() => {});
|
|
1794
|
+
activeOperation.controller.abort(shutdownError);
|
|
1507
1795
|
}
|
|
1508
1796
|
this.cancelQueuedOperations(shutdownError);
|
|
1509
1797
|
}
|
|
1510
1798
|
|
|
1511
1799
|
private requestShutdown(error?: ILiveBrowserError): Promise<void> {
|
|
1512
|
-
if (this.status === 'stopped') {
|
|
1800
|
+
if (this.status === 'stopped' && !this.operationRunning && this.startRequestCount === 0) {
|
|
1801
|
+
this.normalStopRequested = false;
|
|
1513
1802
|
return Promise.resolve();
|
|
1514
1803
|
}
|
|
1515
1804
|
if (this.shutdownPromise) {
|
|
@@ -1622,6 +1911,248 @@ export class LiveBrowserSession {
|
|
|
1622
1911
|
};
|
|
1623
1912
|
}
|
|
1624
1913
|
|
|
1914
|
+
private async releaseExitedOwnedProcess(): Promise<void> {
|
|
1915
|
+
const ownedProcess = this.ownedBrowserProcess;
|
|
1916
|
+
if (!ownedProcess) {
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
if (
|
|
1920
|
+
ownedProcess.childProcess.exitCode === null
|
|
1921
|
+
&& ownedProcess.childProcess.signalCode === null
|
|
1922
|
+
) {
|
|
1923
|
+
await Promise.race([ownedProcess.exitPromise, delay(100)]);
|
|
1924
|
+
if (
|
|
1925
|
+
ownedProcess.childProcess.exitCode === null
|
|
1926
|
+
&& ownedProcess.childProcess.signalCode === null
|
|
1927
|
+
) {
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
if (process.platform === 'linux' && !ownedProcess.identity) {
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
if (ownedProcess.identity) {
|
|
1935
|
+
const members = await listOwnedProcessGroupMembers(ownedProcess.identity);
|
|
1936
|
+
if (members.length > 0) {
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
this.ownedBrowserProcess = undefined;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
private async terminateInternal(
|
|
1944
|
+
gracefulTimeoutMs: number,
|
|
1945
|
+
forceTimeoutMs: number,
|
|
1946
|
+
): Promise<ILiveBrowserTerminationResult> {
|
|
1947
|
+
const errors: string[] = [];
|
|
1948
|
+
let shutdownSettled = this.status === 'stopped';
|
|
1949
|
+
const shutdownObserved = this.stop().then(
|
|
1950
|
+
() => {
|
|
1951
|
+
shutdownSettled = true;
|
|
1952
|
+
},
|
|
1953
|
+
(error) => {
|
|
1954
|
+
errors.push(normalizeErrorMessage(error));
|
|
1955
|
+
shutdownSettled = true;
|
|
1956
|
+
},
|
|
1957
|
+
);
|
|
1958
|
+
const createConfirmedResult = (
|
|
1959
|
+
ownedProcess: IOwnedBrowserProcess | undefined,
|
|
1960
|
+
forced: boolean,
|
|
1961
|
+
): ILiveBrowserTerminationResult => {
|
|
1962
|
+
const childProcess = ownedProcess?.childProcess;
|
|
1963
|
+
const result: ILiveBrowserTerminationResult = {
|
|
1964
|
+
generation: ownedProcess?.generation ?? this.processGeneration,
|
|
1965
|
+
pid: childProcess?.pid ?? null,
|
|
1966
|
+
processGroupId: ownedProcess?.identity?.processGroupId ?? null,
|
|
1967
|
+
running: false,
|
|
1968
|
+
exitCode: childProcess?.exitCode ?? null,
|
|
1969
|
+
signalCode: childProcess?.signalCode ?? null,
|
|
1970
|
+
forced: forced || Boolean(ownedProcess?.forceSignalled),
|
|
1971
|
+
shutdownComplete: true,
|
|
1972
|
+
confirmedDead: true,
|
|
1973
|
+
errors,
|
|
1974
|
+
};
|
|
1975
|
+
if (this.ownedBrowserProcess === ownedProcess) {
|
|
1976
|
+
this.ownedBrowserProcess = undefined;
|
|
1977
|
+
}
|
|
1978
|
+
return result;
|
|
1979
|
+
};
|
|
1980
|
+
const isConfirmedDead = async (
|
|
1981
|
+
ownedProcess: IOwnedBrowserProcess | undefined,
|
|
1982
|
+
): Promise<boolean> => {
|
|
1983
|
+
if (!ownedProcess) {
|
|
1984
|
+
return true;
|
|
1985
|
+
}
|
|
1986
|
+
if (process.platform !== 'linux' || !ownedProcess.identity) {
|
|
1987
|
+
if (process.platform === 'linux' && this.startRequestCount > 0) {
|
|
1988
|
+
return false;
|
|
1989
|
+
}
|
|
1990
|
+
throw new Error('Confirmed browser process-group termination is supported on Linux only');
|
|
1991
|
+
}
|
|
1992
|
+
if (
|
|
1993
|
+
ownedProcess.childProcess.exitCode === null
|
|
1994
|
+
&& ownedProcess.childProcess.signalCode === null
|
|
1995
|
+
) {
|
|
1996
|
+
return false;
|
|
1997
|
+
}
|
|
1998
|
+
const members = await listOwnedProcessGroupMembers(ownedProcess.identity);
|
|
1999
|
+
if (members.length > 0) {
|
|
2000
|
+
return false;
|
|
2001
|
+
}
|
|
2002
|
+
return true;
|
|
2003
|
+
};
|
|
2004
|
+
|
|
2005
|
+
const gracefulDeadline = Date.now() + gracefulTimeoutMs;
|
|
2006
|
+
while (Date.now() < gracefulDeadline) {
|
|
2007
|
+
const ownedProcess = this.ownedBrowserProcess;
|
|
2008
|
+
if (
|
|
2009
|
+
await isConfirmedDead(ownedProcess)
|
|
2010
|
+
&& shutdownSettled
|
|
2011
|
+
&& this.status === 'stopped'
|
|
2012
|
+
) {
|
|
2013
|
+
return createConfirmedResult(ownedProcess, false);
|
|
2014
|
+
}
|
|
2015
|
+
if (shutdownSettled) {
|
|
2016
|
+
await delay(100);
|
|
2017
|
+
} else {
|
|
2018
|
+
await Promise.race([shutdownObserved, delay(100)]);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
const ownedProcess = this.ownedBrowserProcess;
|
|
2023
|
+
let forced = false;
|
|
2024
|
+
if (ownedProcess) {
|
|
2025
|
+
if (process.platform !== 'linux') {
|
|
2026
|
+
throw new Error('Confirmed browser process-group termination is supported on Linux only');
|
|
2027
|
+
}
|
|
2028
|
+
if (ownedProcess.identity) {
|
|
2029
|
+
const membersBeforeFreeze = await listOwnedProcessGroupMembers(ownedProcess.identity);
|
|
2030
|
+
if (membersBeforeFreeze.length > 0) {
|
|
2031
|
+
forced = true;
|
|
2032
|
+
const groupStopped = await signalOwnedProcessGroup(ownedProcess.identity, 'SIGSTOP');
|
|
2033
|
+
if (groupStopped) {
|
|
2034
|
+
killFrozenOwnedProcessGroup(ownedProcess.identity);
|
|
2035
|
+
} else {
|
|
2036
|
+
await this.killOwnedProcessGroupSurvivors(ownedProcess.identity, membersBeforeFreeze);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
} else if (this.startRequestCount === 0) {
|
|
2040
|
+
throw new Error('Owned Chromium process identity was never confirmed');
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
const forceDeadline = Date.now() + forceTimeoutMs;
|
|
2045
|
+
while (Date.now() < forceDeadline) {
|
|
2046
|
+
const currentOwnedProcess = this.ownedBrowserProcess;
|
|
2047
|
+
if (
|
|
2048
|
+
await isConfirmedDead(currentOwnedProcess)
|
|
2049
|
+
&& shutdownSettled
|
|
2050
|
+
&& this.status === 'stopped'
|
|
2051
|
+
) {
|
|
2052
|
+
return createConfirmedResult(currentOwnedProcess, forced);
|
|
2053
|
+
}
|
|
2054
|
+
if (shutdownSettled) {
|
|
2055
|
+
await delay(100);
|
|
2056
|
+
} else {
|
|
2057
|
+
await Promise.race([shutdownObserved, delay(100)]);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
const remainingOwnedProcess = this.ownedBrowserProcess;
|
|
2062
|
+
if (remainingOwnedProcess?.identity) {
|
|
2063
|
+
const survivors = await listOwnedProcessGroupMembers(remainingOwnedProcess.identity);
|
|
2064
|
+
if (survivors.length > 0) {
|
|
2065
|
+
throw new Error(
|
|
2066
|
+
`Owned Chromium process group did not terminate: ${survivors.map((item) => item.pid).join(', ')}`,
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
if (!shutdownSettled || this.status !== 'stopped') {
|
|
2071
|
+
throw new Error(
|
|
2072
|
+
'Owned Chromium process group exited but LiveBrowserSession shutdown did not settle',
|
|
2073
|
+
);
|
|
2074
|
+
}
|
|
2075
|
+
throw new Error('Owned Chromium process exit was not confirmed by Node.js');
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
private async killOwnedProcessGroupSurvivors(
|
|
2079
|
+
rootIdentity: IOwnedProcessIdentity,
|
|
2080
|
+
initialMembers: IOwnedProcessIdentity[],
|
|
2081
|
+
): Promise<void> {
|
|
2082
|
+
let previousKeys = new Set(initialMembers.map((member) => `${member.pid}:${member.startTime}`));
|
|
2083
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
2084
|
+
const members = await listOwnedProcessGroupMembers(rootIdentity);
|
|
2085
|
+
if (members.length === 0) {
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
for (const member of members) {
|
|
2089
|
+
await signalOwnedProcessIdentity(member, 'SIGSTOP');
|
|
2090
|
+
}
|
|
2091
|
+
await delay(10);
|
|
2092
|
+
const frozenMembers = await listOwnedProcessGroupMembers(rootIdentity);
|
|
2093
|
+
const currentKeys = new Set(
|
|
2094
|
+
frozenMembers.map((member) => `${member.pid}:${member.startTime}`),
|
|
2095
|
+
);
|
|
2096
|
+
const sameMembers = currentKeys.size === previousKeys.size
|
|
2097
|
+
&& [...currentKeys].every((key) => previousKeys.has(key));
|
|
2098
|
+
const allFrozen = frozenMembers.every((member) => (
|
|
2099
|
+
member.state === 'T' || member.state === 't' || member.state === 'Z'
|
|
2100
|
+
));
|
|
2101
|
+
if (sameMembers && allFrozen) {
|
|
2102
|
+
for (const member of frozenMembers) {
|
|
2103
|
+
await signalOwnedProcessIdentity(member, 'SIGKILL');
|
|
2104
|
+
}
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
previousKeys = currentKeys;
|
|
2108
|
+
}
|
|
2109
|
+
throw new Error('Unable to stabilize the surviving Chromium process group');
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
private async forceOwnedBrowserProcessGroup(
|
|
2113
|
+
ownedProcess: IOwnedBrowserProcess,
|
|
2114
|
+
): Promise<void> {
|
|
2115
|
+
ownedProcess.forceSignalled = true;
|
|
2116
|
+
if (process.platform !== 'linux') {
|
|
2117
|
+
ownedProcess.childProcess.kill('SIGKILL');
|
|
2118
|
+
await Promise.race([ownedProcess.exitPromise, delay(5000)]);
|
|
2119
|
+
if (
|
|
2120
|
+
ownedProcess.childProcess.exitCode === null
|
|
2121
|
+
&& ownedProcess.childProcess.signalCode === null
|
|
2122
|
+
) {
|
|
2123
|
+
throw new Error('Owned Chromium process did not exit after SIGKILL');
|
|
2124
|
+
}
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
if (!ownedProcess.identity) {
|
|
2128
|
+
throw new Error('Owned Chromium process identity was never confirmed');
|
|
2129
|
+
}
|
|
2130
|
+
const members = await listOwnedProcessGroupMembers(ownedProcess.identity);
|
|
2131
|
+
if (members.length > 0) {
|
|
2132
|
+
const groupStopped = await signalOwnedProcessGroup(ownedProcess.identity, 'SIGSTOP');
|
|
2133
|
+
if (groupStopped) {
|
|
2134
|
+
killFrozenOwnedProcessGroup(ownedProcess.identity);
|
|
2135
|
+
} else {
|
|
2136
|
+
await this.killOwnedProcessGroupSurvivors(ownedProcess.identity, members);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
const deadline = Date.now() + 5000;
|
|
2140
|
+
while (Date.now() < deadline) {
|
|
2141
|
+
const survivors = await listOwnedProcessGroupMembers(ownedProcess.identity);
|
|
2142
|
+
if (survivors.length === 0) {
|
|
2143
|
+
await Promise.race([ownedProcess.exitPromise, delay(100)]);
|
|
2144
|
+
if (
|
|
2145
|
+
ownedProcess.childProcess.exitCode !== null
|
|
2146
|
+
|| ownedProcess.childProcess.signalCode !== null
|
|
2147
|
+
) {
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
await delay(100);
|
|
2152
|
+
}
|
|
2153
|
+
throw new Error('Owned Chromium process group survived forced startup cleanup');
|
|
2154
|
+
}
|
|
2155
|
+
|
|
1625
2156
|
private async stopInternal(error?: ILiveBrowserError): Promise<void> {
|
|
1626
2157
|
if (this.status === 'stopped') {
|
|
1627
2158
|
return;
|
|
@@ -1631,17 +2162,31 @@ export class LiveBrowserSession {
|
|
|
1631
2162
|
this.lastError = { ...error };
|
|
1632
2163
|
}
|
|
1633
2164
|
this.emitState();
|
|
1634
|
-
if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
|
|
1635
|
-
this.browserLifetimeController.abort(new Error('LiveBrowserSession is stopping'));
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
2165
|
const browser = this.browser;
|
|
2166
|
+
if (browser && this.browserTargetCreatedListener) {
|
|
2167
|
+
browser.off('targetcreated', this.browserTargetCreatedListener);
|
|
2168
|
+
}
|
|
2169
|
+
this.browserTargetCreatedListener = undefined;
|
|
1639
2170
|
if (browser && this.browserDisconnectedListener) {
|
|
1640
2171
|
browser.off('disconnected', this.browserDisconnectedListener);
|
|
1641
2172
|
}
|
|
1642
2173
|
this.browserDisconnectedListener = undefined;
|
|
1643
2174
|
|
|
1644
2175
|
const shutdownErrors: unknown[] = [];
|
|
2176
|
+
const shutdownFrameDrainPromise = this.shutdownFrameDrainPromise;
|
|
2177
|
+
this.shutdownFrameDrainPromise = undefined;
|
|
2178
|
+
if (shutdownFrameDrainPromise) {
|
|
2179
|
+
try {
|
|
2180
|
+
await shutdownFrameDrainPromise;
|
|
2181
|
+
} catch (cleanupError) {
|
|
2182
|
+
shutdownErrors.push(cleanupError);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
try {
|
|
2186
|
+
await this.teardownProxySecurity();
|
|
2187
|
+
} catch (cleanupError) {
|
|
2188
|
+
shutdownErrors.push(cleanupError);
|
|
2189
|
+
}
|
|
1645
2190
|
for (const tab of [...this.tabs.values()]) {
|
|
1646
2191
|
tab.closing = true;
|
|
1647
2192
|
const securityCdpSession = tab.securityCdpSession;
|
|
@@ -1669,12 +2214,24 @@ export class LiveBrowserSession {
|
|
|
1669
2214
|
} catch (cleanupError) {
|
|
1670
2215
|
shutdownErrors.push(cleanupError);
|
|
1671
2216
|
}
|
|
2217
|
+
await this.waitForCdpFrameAcknowledgements();
|
|
2218
|
+
if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
|
|
2219
|
+
this.browserLifetimeController.abort(new Error('LiveBrowserSession is stopping'));
|
|
2220
|
+
}
|
|
1672
2221
|
|
|
1673
2222
|
if (browser) {
|
|
1674
2223
|
try {
|
|
1675
2224
|
await browser.close();
|
|
1676
2225
|
} catch (closeError) {
|
|
1677
2226
|
shutdownErrors.push(closeError);
|
|
2227
|
+
const ownedProcess = this.ownedBrowserProcess;
|
|
2228
|
+
if (ownedProcess) {
|
|
2229
|
+
try {
|
|
2230
|
+
await this.forceOwnedBrowserProcessGroup(ownedProcess);
|
|
2231
|
+
} catch (forceError) {
|
|
2232
|
+
shutdownErrors.push(forceError);
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
1678
2235
|
}
|
|
1679
2236
|
}
|
|
1680
2237
|
|
|
@@ -1689,7 +2246,7 @@ export class LiveBrowserSession {
|
|
|
1689
2246
|
this.activeTabId = null;
|
|
1690
2247
|
this.status = 'stopped';
|
|
1691
2248
|
this.emitState();
|
|
1692
|
-
if (shutdownErrors.length > 0
|
|
2249
|
+
if (shutdownErrors.length > 0) {
|
|
1693
2250
|
throw new AggregateError(shutdownErrors, 'LiveBrowserSession shutdown was incomplete');
|
|
1694
2251
|
}
|
|
1695
2252
|
}
|
|
@@ -1697,19 +2254,444 @@ export class LiveBrowserSession {
|
|
|
1697
2254
|
private async configureBrowserSecurity(
|
|
1698
2255
|
browser: plugins.puppeteer.Browser,
|
|
1699
2256
|
): Promise<void> {
|
|
1700
|
-
|
|
2257
|
+
const denyPermissions = this.options.security?.denyPermissions;
|
|
2258
|
+
const proxyCredentials = this.options.security?.proxyCredentials;
|
|
2259
|
+
if (!denyPermissions && !proxyCredentials) {
|
|
1701
2260
|
return;
|
|
1702
2261
|
}
|
|
1703
2262
|
const cdpSession = await browser.target().createCDPSession();
|
|
2263
|
+
if (!proxyCredentials) {
|
|
2264
|
+
try {
|
|
2265
|
+
if (denyPermissions) {
|
|
2266
|
+
await cdpSession.send('Browser.grantPermissions', { permissions: [] });
|
|
2267
|
+
}
|
|
2268
|
+
} finally {
|
|
2269
|
+
if (!cdpSession.detached) {
|
|
2270
|
+
await cdpSession.detach();
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
const generation = this.ownedBrowserProcess?.generation;
|
|
2277
|
+
if (generation === undefined) {
|
|
2278
|
+
throw new Error('Proxy security requires an owned browser generation');
|
|
2279
|
+
}
|
|
2280
|
+
this.proxySecurityStopping = false;
|
|
2281
|
+
this.proxySecurityGeneration = generation;
|
|
2282
|
+
this.browserSecurityCdpSession = cdpSession;
|
|
1704
2283
|
try {
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
2284
|
+
if (denyPermissions) {
|
|
2285
|
+
await cdpSession.send('Browser.grantPermissions', { permissions: [] });
|
|
2286
|
+
}
|
|
2287
|
+
const browserSecurityConnection = cdpSession.connection();
|
|
2288
|
+
if (!browserSecurityConnection) {
|
|
2289
|
+
throw new Error('Browser security CDP session has no connection');
|
|
2290
|
+
}
|
|
2291
|
+
this.browserSecurityConnection = browserSecurityConnection;
|
|
2292
|
+
this.browserSecuritySessionAttachedListener = (attachedSession) => {
|
|
2293
|
+
this.trackProxySecurityOperation(
|
|
2294
|
+
this.configureProxySecuritySession(attachedSession, generation),
|
|
2295
|
+
generation,
|
|
2296
|
+
'proxy_security_session_setup_failed',
|
|
2297
|
+
);
|
|
2298
|
+
};
|
|
2299
|
+
this.browserSecuritySessionDetachedListener = (detachedSession) => {
|
|
2300
|
+
if (detachedSession === cdpSession) {
|
|
2301
|
+
this.handleProxySecurityFailure(
|
|
2302
|
+
'proxy_security_browser_session_detached',
|
|
2303
|
+
new Error('Browser security CDP session detached unexpectedly'),
|
|
2304
|
+
generation,
|
|
2305
|
+
);
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
this.trackProxySecurityOperation(
|
|
2309
|
+
this.verifyProxySecuritySessionDetached(detachedSession, generation),
|
|
2310
|
+
generation,
|
|
2311
|
+
'proxy_security_detach_verification_failed',
|
|
2312
|
+
);
|
|
2313
|
+
};
|
|
2314
|
+
browserSecurityConnection.on(
|
|
2315
|
+
'sessionattached',
|
|
2316
|
+
this.browserSecuritySessionAttachedListener,
|
|
2317
|
+
);
|
|
2318
|
+
browserSecurityConnection.on(
|
|
2319
|
+
'sessiondetached',
|
|
2320
|
+
this.browserSecuritySessionDetachedListener,
|
|
2321
|
+
);
|
|
2322
|
+
|
|
2323
|
+
for (const target of browser.targets()) {
|
|
2324
|
+
if (!proxySeedTargetTypes.has(target.type())) {
|
|
2325
|
+
continue;
|
|
2326
|
+
}
|
|
2327
|
+
const securitySession = await target.createCDPSession();
|
|
2328
|
+
const securityRecord = this.proxySecuritySessions.get(securitySession.id());
|
|
2329
|
+
if (!securityRecord) {
|
|
2330
|
+
throw new Error(`Proxy security did not observe session ${securitySession.id()}`);
|
|
2331
|
+
}
|
|
2332
|
+
securityRecord.ownedByProxySecurity = true;
|
|
2333
|
+
await securityRecord.setupPromise;
|
|
2334
|
+
}
|
|
2335
|
+
} catch (error) {
|
|
2336
|
+
await this.teardownProxySecurity();
|
|
2337
|
+
throw error;
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
private configureProxySecuritySession(
|
|
2342
|
+
session: plugins.puppeteer.CDPSession,
|
|
2343
|
+
generation: number,
|
|
2344
|
+
): Promise<void> {
|
|
2345
|
+
const existing = this.proxySecuritySessions.get(session.id());
|
|
2346
|
+
if (existing) {
|
|
2347
|
+
if (existing.generation !== generation) {
|
|
2348
|
+
return Promise.reject(new Error(`CDP session ${session.id()} crossed browser generations`));
|
|
2349
|
+
}
|
|
2350
|
+
return existing.setupPromise;
|
|
2351
|
+
}
|
|
2352
|
+
if (this.proxySecuritySessions.size >= maxProxySecuritySessions) {
|
|
2353
|
+
return Promise.reject(
|
|
2354
|
+
new Error(`Proxy security exceeded ${maxProxySecuritySessions} CDP sessions`),
|
|
2355
|
+
);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
const attemptedAuthentications = new Set<string>();
|
|
2359
|
+
const requestIdsByNetworkId = new Map<string, string>();
|
|
2360
|
+
const handleProtocolFailure = (operation: string, error: unknown): void => {
|
|
2361
|
+
this.handleProxySecurityFailure(
|
|
2362
|
+
'proxy_security_protocol_failed',
|
|
2363
|
+
new Error(`${operation}: ${normalizeErrorMessage(error)}`),
|
|
2364
|
+
generation,
|
|
2365
|
+
);
|
|
2366
|
+
};
|
|
2367
|
+
const authRequiredListener = (event: TProxyAuthRequiredEvent): void => {
|
|
2368
|
+
const credentials = this.options.security?.proxyCredentials;
|
|
2369
|
+
const totalTrackedRequests = [...this.proxySecuritySessions.values()].reduce(
|
|
2370
|
+
(total, record) => total
|
|
2371
|
+
+ record.attemptedAuthentications.size
|
|
2372
|
+
+ record.requestIdsByNetworkId.size,
|
|
2373
|
+
0,
|
|
2374
|
+
);
|
|
2375
|
+
const hasTrackingCapacity = attemptedAuthentications.size < maxTrackedProxyRequests
|
|
2376
|
+
&& totalTrackedRequests < maxTotalTrackedProxyRequests;
|
|
2377
|
+
const isFirstProxyAttempt = event.authChallenge.source === 'Proxy'
|
|
2378
|
+
&& !attemptedAuthentications.has(event.requestId)
|
|
2379
|
+
&& hasTrackingCapacity;
|
|
2380
|
+
if (isFirstProxyAttempt) {
|
|
2381
|
+
attemptedAuthentications.add(event.requestId);
|
|
2382
|
+
} else if (
|
|
2383
|
+
event.authChallenge.source === 'Proxy'
|
|
2384
|
+
&& !attemptedAuthentications.has(event.requestId)
|
|
2385
|
+
&& !hasTrackingCapacity
|
|
2386
|
+
) {
|
|
2387
|
+
handleProtocolFailure(
|
|
2388
|
+
'Proxy authentication tracking failed',
|
|
2389
|
+
new Error(`A security session exceeded ${maxTrackedProxyRequests} tracked requests`),
|
|
2390
|
+
);
|
|
2391
|
+
}
|
|
2392
|
+
const authChallengeResponse = isFirstProxyAttempt && credentials
|
|
2393
|
+
? {
|
|
2394
|
+
response: 'ProvideCredentials' as const,
|
|
2395
|
+
username: credentials.username,
|
|
2396
|
+
password: credentials.password,
|
|
2397
|
+
}
|
|
2398
|
+
: { response: 'CancelAuth' as const };
|
|
2399
|
+
this.trackProxySecurityOperation(
|
|
2400
|
+
session.send('Fetch.continueWithAuth', {
|
|
2401
|
+
requestId: event.requestId,
|
|
2402
|
+
authChallengeResponse,
|
|
2403
|
+
}).then(() => undefined),
|
|
2404
|
+
generation,
|
|
2405
|
+
'proxy_security_protocol_failed',
|
|
2406
|
+
);
|
|
2407
|
+
};
|
|
2408
|
+
const requestPausedListener = (event: TProxyRequestPausedEvent): void => {
|
|
2409
|
+
if (event.networkId) {
|
|
2410
|
+
const totalTrackedRequests = [...this.proxySecuritySessions.values()].reduce(
|
|
2411
|
+
(total, record) => total
|
|
2412
|
+
+ record.attemptedAuthentications.size
|
|
2413
|
+
+ record.requestIdsByNetworkId.size,
|
|
2414
|
+
0,
|
|
2415
|
+
);
|
|
2416
|
+
if (
|
|
2417
|
+
!requestIdsByNetworkId.has(event.networkId)
|
|
2418
|
+
&& (
|
|
2419
|
+
requestIdsByNetworkId.size >= maxTrackedProxyRequests
|
|
2420
|
+
|| totalTrackedRequests >= maxTotalTrackedProxyRequests
|
|
2421
|
+
)
|
|
2422
|
+
) {
|
|
2423
|
+
handleProtocolFailure(
|
|
2424
|
+
'Proxy request tracking failed',
|
|
2425
|
+
new Error(`A security session exceeded ${maxTrackedProxyRequests} tracked requests`),
|
|
2426
|
+
);
|
|
2427
|
+
} else {
|
|
2428
|
+
const previousRequestId = requestIdsByNetworkId.get(event.networkId);
|
|
2429
|
+
if (previousRequestId && previousRequestId !== event.requestId) {
|
|
2430
|
+
attemptedAuthentications.delete(previousRequestId);
|
|
2431
|
+
}
|
|
2432
|
+
requestIdsByNetworkId.set(event.networkId, event.requestId);
|
|
2433
|
+
}
|
|
1709
2434
|
}
|
|
2435
|
+
this.trackProxySecurityOperation(
|
|
2436
|
+
session.send('Fetch.continueRequest', { requestId: event.requestId }).then(() => undefined),
|
|
2437
|
+
generation,
|
|
2438
|
+
'proxy_security_protocol_failed',
|
|
2439
|
+
);
|
|
2440
|
+
};
|
|
2441
|
+
const forgetAuthentication = (networkId: string): void => {
|
|
2442
|
+
const requestId = requestIdsByNetworkId.get(networkId);
|
|
2443
|
+
if (!requestId) {
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
requestIdsByNetworkId.delete(networkId);
|
|
2447
|
+
attemptedAuthentications.delete(requestId);
|
|
2448
|
+
};
|
|
2449
|
+
const loadingFinishedListener = (event: TNetworkLoadingFinishedEvent): void => {
|
|
2450
|
+
forgetAuthentication(event.requestId);
|
|
2451
|
+
};
|
|
2452
|
+
const loadingFailedListener = (event: TNetworkLoadingFailedEvent): void => {
|
|
2453
|
+
forgetAuthentication(event.requestId);
|
|
2454
|
+
};
|
|
2455
|
+
session.on('Fetch.authRequired', authRequiredListener);
|
|
2456
|
+
session.on('Fetch.requestPaused', requestPausedListener);
|
|
2457
|
+
session.on('Network.loadingFinished', loadingFinishedListener);
|
|
2458
|
+
session.on('Network.loadingFailed', loadingFailedListener);
|
|
2459
|
+
|
|
2460
|
+
const proxySecuritySession: IProxySecuritySession = {
|
|
2461
|
+
session,
|
|
2462
|
+
generation,
|
|
2463
|
+
fetchEnabled: false,
|
|
2464
|
+
allowLiveTargetDetach: false,
|
|
2465
|
+
ownedByProxySecurity: false,
|
|
2466
|
+
attemptedAuthentications,
|
|
2467
|
+
requestIdsByNetworkId,
|
|
2468
|
+
authRequiredListener,
|
|
2469
|
+
requestPausedListener,
|
|
2470
|
+
loadingFinishedListener,
|
|
2471
|
+
loadingFailedListener,
|
|
2472
|
+
setupPromise: Promise.resolve(),
|
|
2473
|
+
};
|
|
2474
|
+
const targetInfoPromise = session.send('Target.getTargetInfo');
|
|
2475
|
+
const networkEnablePromise = session.send('Network.enable');
|
|
2476
|
+
const fetchEnablePromise = session.send('Fetch.enable', {
|
|
2477
|
+
handleAuthRequests: true,
|
|
2478
|
+
patterns: [{ urlPattern: '*' }],
|
|
2479
|
+
});
|
|
2480
|
+
const commandResultsPromise = Promise.allSettled([
|
|
2481
|
+
targetInfoPromise,
|
|
2482
|
+
networkEnablePromise,
|
|
2483
|
+
fetchEnablePromise,
|
|
2484
|
+
]);
|
|
2485
|
+
const setupPromise = (async (): Promise<void> => {
|
|
2486
|
+
try {
|
|
2487
|
+
const [targetInfoResult, networkResult, fetchResult] = await commandResultsPromise;
|
|
2488
|
+
if (targetInfoResult.status === 'rejected') {
|
|
2489
|
+
throw targetInfoResult.reason;
|
|
2490
|
+
}
|
|
2491
|
+
const { targetInfo } = targetInfoResult.value;
|
|
2492
|
+
proxySecuritySession.targetId = targetInfo.targetId;
|
|
2493
|
+
proxySecuritySession.targetType = targetInfo.type;
|
|
2494
|
+
if (fetchResult.status === 'rejected') {
|
|
2495
|
+
const unsupportedTarget = proxyFetchUnsupportedTargetTypes.has(targetInfo.type)
|
|
2496
|
+
&& fetchResult.reason instanceof plugins.puppeteer.ProtocolError
|
|
2497
|
+
&& fetchResult.reason.originalMessage === "'Fetch.enable' wasn't found";
|
|
2498
|
+
if (unsupportedTarget) {
|
|
2499
|
+
this.removeProxySecuritySession(proxySecuritySession);
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
throw fetchResult.reason;
|
|
2503
|
+
}
|
|
2504
|
+
if (networkResult.status === 'rejected') {
|
|
2505
|
+
throw networkResult.reason;
|
|
2506
|
+
}
|
|
2507
|
+
proxySecuritySession.fetchEnabled = true;
|
|
2508
|
+
} catch (error) {
|
|
2509
|
+
this.removeProxySecuritySession(proxySecuritySession);
|
|
2510
|
+
if (session.detached || this.proxySecurityStopping || this.normalStopRequested) {
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
throw error;
|
|
2514
|
+
}
|
|
2515
|
+
})();
|
|
2516
|
+
proxySecuritySession.setupPromise = setupPromise;
|
|
2517
|
+
this.proxySecuritySessions.set(session.id(), proxySecuritySession);
|
|
2518
|
+
return setupPromise;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
private async verifyProxySecuritySessionDetached(
|
|
2522
|
+
session: plugins.puppeteer.CDPSession,
|
|
2523
|
+
generation: number,
|
|
2524
|
+
): Promise<void> {
|
|
2525
|
+
const proxySecuritySession = this.proxySecuritySessions.get(session.id());
|
|
2526
|
+
if (!proxySecuritySession || proxySecuritySession.generation !== generation) {
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
this.removeProxySecuritySession(proxySecuritySession);
|
|
2530
|
+
if (this.proxySecurityStopping || this.normalStopRequested || this.status !== 'running') {
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
if (proxySecuritySession.allowLiveTargetDetach) {
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
if (!proxySecuritySession.targetId) {
|
|
2537
|
+
throw new Error(`Proxy security session detached before target identification: ${session.id()}`);
|
|
2538
|
+
}
|
|
2539
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2540
|
+
const generationSessions = [...this.proxySecuritySessions.values()].filter((candidate) => (
|
|
2541
|
+
candidate.generation === generation
|
|
2542
|
+
));
|
|
2543
|
+
await Promise.allSettled(generationSessions.map((candidate) => candidate.setupPromise));
|
|
2544
|
+
const hasReplacement = [...this.proxySecuritySessions.values()].some((candidate) => (
|
|
2545
|
+
candidate.generation === generation
|
|
2546
|
+
&& candidate.targetId === proxySecuritySession.targetId
|
|
2547
|
+
&& candidate.fetchEnabled
|
|
2548
|
+
&& !candidate.session.detached
|
|
2549
|
+
));
|
|
2550
|
+
if (hasReplacement) {
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
if (attempt < 2) {
|
|
2554
|
+
await delay(25);
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
const browserSecurityCdpSession = this.browserSecurityCdpSession;
|
|
2558
|
+
if (!browserSecurityCdpSession || browserSecurityCdpSession.detached) {
|
|
2559
|
+
throw new Error('Browser security CDP session detached unexpectedly');
|
|
2560
|
+
}
|
|
2561
|
+
const { targetInfos } = await browserSecurityCdpSession.send('Target.getTargets');
|
|
2562
|
+
if (targetInfos.some((targetInfo) => targetInfo.targetId === proxySecuritySession.targetId)) {
|
|
2563
|
+
throw new Error(
|
|
2564
|
+
`Proxy security detached from live target ${proxySecuritySession.targetId}`,
|
|
2565
|
+
);
|
|
1710
2566
|
}
|
|
1711
2567
|
}
|
|
1712
2568
|
|
|
2569
|
+
private removeProxySecuritySession(proxySecuritySession: IProxySecuritySession): void {
|
|
2570
|
+
if (this.proxySecuritySessions.get(proxySecuritySession.session.id()) !== proxySecuritySession) {
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
proxySecuritySession.session.off('Fetch.authRequired', proxySecuritySession.authRequiredListener);
|
|
2574
|
+
proxySecuritySession.session.off('Fetch.requestPaused', proxySecuritySession.requestPausedListener);
|
|
2575
|
+
proxySecuritySession.session.off(
|
|
2576
|
+
'Network.loadingFinished',
|
|
2577
|
+
proxySecuritySession.loadingFinishedListener,
|
|
2578
|
+
);
|
|
2579
|
+
proxySecuritySession.session.off(
|
|
2580
|
+
'Network.loadingFailed',
|
|
2581
|
+
proxySecuritySession.loadingFailedListener,
|
|
2582
|
+
);
|
|
2583
|
+
proxySecuritySession.attemptedAuthentications.clear();
|
|
2584
|
+
proxySecuritySession.requestIdsByNetworkId.clear();
|
|
2585
|
+
this.proxySecuritySessions.delete(proxySecuritySession.session.id());
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
private allowOperationalCdpSessionDetach(session: plugins.puppeteer.CDPSession): void {
|
|
2589
|
+
const proxySecuritySession = this.proxySecuritySessions.get(session.id());
|
|
2590
|
+
if (proxySecuritySession) {
|
|
2591
|
+
proxySecuritySession.allowLiveTargetDetach = true;
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
private trackProxySecurityOperation(
|
|
2596
|
+
operation: Promise<void>,
|
|
2597
|
+
generation: number,
|
|
2598
|
+
errorCode: string,
|
|
2599
|
+
): void {
|
|
2600
|
+
if (this.proxySecurityOperations.size >= maxProxySecurityOperations) {
|
|
2601
|
+
void operation.catch(() => undefined);
|
|
2602
|
+
this.handleProxySecurityFailure(
|
|
2603
|
+
'proxy_security_operation_capacity_exceeded',
|
|
2604
|
+
new Error(`Proxy security exceeded ${maxProxySecurityOperations} protocol operations`),
|
|
2605
|
+
generation,
|
|
2606
|
+
);
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2609
|
+
let trackedOperation: Promise<void>;
|
|
2610
|
+
trackedOperation = operation.catch((error) => {
|
|
2611
|
+
this.handleProxySecurityFailure(errorCode, error, generation);
|
|
2612
|
+
}).finally(() => {
|
|
2613
|
+
this.proxySecurityOperations.delete(trackedOperation);
|
|
2614
|
+
});
|
|
2615
|
+
this.proxySecurityOperations.add(trackedOperation);
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
private handleProxySecurityFailure(
|
|
2619
|
+
code: string,
|
|
2620
|
+
error: unknown,
|
|
2621
|
+
generation = this.proxySecurityGeneration,
|
|
2622
|
+
): void {
|
|
2623
|
+
if (
|
|
2624
|
+
generation !== this.proxySecurityGeneration
|
|
2625
|
+
|| this.proxySecurityStopping
|
|
2626
|
+
|| this.normalStopRequested
|
|
2627
|
+
|| this.status === 'stopped'
|
|
2628
|
+
|| this.status === 'stopping'
|
|
2629
|
+
) {
|
|
2630
|
+
return;
|
|
2631
|
+
}
|
|
2632
|
+
const liveBrowserError: ILiveBrowserError = {
|
|
2633
|
+
code,
|
|
2634
|
+
message: normalizeErrorMessage(error),
|
|
2635
|
+
fatal: true,
|
|
2636
|
+
};
|
|
2637
|
+
this.emitError(liveBrowserError);
|
|
2638
|
+
this.beginShutdown(true);
|
|
2639
|
+
void this.terminate().catch((terminationError) => {
|
|
2640
|
+
this.emitError({
|
|
2641
|
+
code: 'proxy_security_termination_failed',
|
|
2642
|
+
message: normalizeErrorMessage(terminationError),
|
|
2643
|
+
fatal: true,
|
|
2644
|
+
});
|
|
2645
|
+
});
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
private async teardownProxySecurity(): Promise<void> {
|
|
2649
|
+
this.proxySecurityStopping = true;
|
|
2650
|
+
if (this.browserSecurityConnection && this.browserSecuritySessionAttachedListener) {
|
|
2651
|
+
this.browserSecurityConnection.off(
|
|
2652
|
+
'sessionattached',
|
|
2653
|
+
this.browserSecuritySessionAttachedListener,
|
|
2654
|
+
);
|
|
2655
|
+
}
|
|
2656
|
+
if (this.browserSecurityConnection && this.browserSecuritySessionDetachedListener) {
|
|
2657
|
+
this.browserSecurityConnection.off(
|
|
2658
|
+
'sessiondetached',
|
|
2659
|
+
this.browserSecuritySessionDetachedListener,
|
|
2660
|
+
);
|
|
2661
|
+
}
|
|
2662
|
+
this.browserSecuritySessionAttachedListener = undefined;
|
|
2663
|
+
this.browserSecuritySessionDetachedListener = undefined;
|
|
2664
|
+
this.browserSecurityConnection = undefined;
|
|
2665
|
+
const ownedSessions = [...this.proxySecuritySessions.values()]
|
|
2666
|
+
.filter((record) => record.ownedByProxySecurity)
|
|
2667
|
+
.map((record) => record.session);
|
|
2668
|
+
for (const proxySecuritySession of [...this.proxySecuritySessions.values()]) {
|
|
2669
|
+
this.removeProxySecuritySession(proxySecuritySession);
|
|
2670
|
+
}
|
|
2671
|
+
for (const ownedSession of ownedSessions) {
|
|
2672
|
+
if (!ownedSession.detached) {
|
|
2673
|
+
try {
|
|
2674
|
+
await ownedSession.detach();
|
|
2675
|
+
} catch {
|
|
2676
|
+
// Browser shutdown can close a target before explicit detach settles.
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
const browserSecurityCdpSession = this.browserSecurityCdpSession;
|
|
2681
|
+
this.browserSecurityCdpSession = undefined;
|
|
2682
|
+
if (browserSecurityCdpSession && !browserSecurityCdpSession.detached) {
|
|
2683
|
+
try {
|
|
2684
|
+
await browserSecurityCdpSession.detach();
|
|
2685
|
+
} catch {
|
|
2686
|
+
// Browser shutdown can detach the browser target first.
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
while (this.proxySecurityOperations.size > 0) {
|
|
2690
|
+
await Promise.allSettled([...this.proxySecurityOperations]);
|
|
2691
|
+
}
|
|
2692
|
+
this.proxySecurityGeneration = 0;
|
|
2693
|
+
}
|
|
2694
|
+
|
|
1713
2695
|
private requireBrowserContext(): plugins.puppeteer.BrowserContext {
|
|
1714
2696
|
if (this.status !== 'running' || !this.browserContext) {
|
|
1715
2697
|
throw new Error('LiveBrowserSession is not running');
|
|
@@ -1776,6 +2758,68 @@ export class LiveBrowserSession {
|
|
|
1776
2758
|
}
|
|
1777
2759
|
}
|
|
1778
2760
|
|
|
2761
|
+
private scheduleDiscoveredPageRegistration(page: plugins.puppeteer.Page): void {
|
|
2762
|
+
if (
|
|
2763
|
+
page.isClosed()
|
|
2764
|
+
|| this.tabIdsByPage.has(page)
|
|
2765
|
+
|| this.normalStopRequested
|
|
2766
|
+
|| this.status === 'stopped'
|
|
2767
|
+
|| this.status === 'stopping'
|
|
2768
|
+
) {
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
const wasScheduled = this.scheduleOperation(async () => {
|
|
2772
|
+
if (page.isClosed() || this.tabIdsByPage.has(page)) {
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
const previousActiveTabId = this.activeTabId;
|
|
2776
|
+
let discoveredTab: IPrivateLiveBrowserTab | undefined;
|
|
2777
|
+
try {
|
|
2778
|
+
discoveredTab = await this.registerPage(page);
|
|
2779
|
+
await this.activateTabInternal(discoveredTab.id);
|
|
2780
|
+
} catch (error) {
|
|
2781
|
+
const rollbackError = await this.rollbackCreatedPage(
|
|
2782
|
+
page,
|
|
2783
|
+
discoveredTab,
|
|
2784
|
+
previousActiveTabId,
|
|
2785
|
+
);
|
|
2786
|
+
if (rollbackError) {
|
|
2787
|
+
const aggregateError = new AggregateError(
|
|
2788
|
+
[error, rollbackError],
|
|
2789
|
+
'Discovered page registration rollback failed',
|
|
2790
|
+
);
|
|
2791
|
+
this.handleDiscoveredPageFailure(aggregateError);
|
|
2792
|
+
throw aggregateError;
|
|
2793
|
+
}
|
|
2794
|
+
throw error;
|
|
2795
|
+
}
|
|
2796
|
+
}, 'discovered_page_registration_failed', undefined, true);
|
|
2797
|
+
if (!wasScheduled) {
|
|
2798
|
+
this.handleDiscoveredPageFailure(
|
|
2799
|
+
new Error('A discovered page could not be admitted to the internal operation queue'),
|
|
2800
|
+
);
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
private handleDiscoveredPageFailure(error: unknown): void {
|
|
2805
|
+
if (this.normalStopRequested || this.status === 'stopped' || this.status === 'stopping') {
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
const liveBrowserError: ILiveBrowserError = {
|
|
2809
|
+
code: 'untracked_page_detected',
|
|
2810
|
+
message: normalizeErrorMessage(error),
|
|
2811
|
+
fatal: true,
|
|
2812
|
+
};
|
|
2813
|
+
this.emitError(liveBrowserError);
|
|
2814
|
+
void this.requestShutdown(liveBrowserError).catch((shutdownError) => {
|
|
2815
|
+
this.emitError({
|
|
2816
|
+
code: 'untracked_page_shutdown_failed',
|
|
2817
|
+
message: normalizeErrorMessage(shutdownError),
|
|
2818
|
+
fatal: true,
|
|
2819
|
+
});
|
|
2820
|
+
});
|
|
2821
|
+
}
|
|
2822
|
+
|
|
1779
2823
|
private async registerPage(page: plugins.puppeteer.Page): Promise<IPrivateLiveBrowserTab> {
|
|
1780
2824
|
const existingTabId = this.tabIdsByPage.get(page);
|
|
1781
2825
|
if (existingTabId) {
|
|
@@ -1807,6 +2851,7 @@ export class LiveBrowserSession {
|
|
|
1807
2851
|
try {
|
|
1808
2852
|
if (this.options.security?.denyFileChoosers) {
|
|
1809
2853
|
const securityCdpSession = await page.createCDPSession();
|
|
2854
|
+
this.allowOperationalCdpSessionDetach(securityCdpSession);
|
|
1810
2855
|
tab.securityCdpSession = securityCdpSession;
|
|
1811
2856
|
await securityCdpSession.send('Page.enable', {
|
|
1812
2857
|
enableFileChooserOpenedEvent: true,
|
|
@@ -2270,6 +3315,7 @@ export class LiveBrowserSession {
|
|
|
2270
3315
|
await this.ensureTabViewport(tab);
|
|
2271
3316
|
|
|
2272
3317
|
const cdpSession = await tab.page.createCDPSession();
|
|
3318
|
+
this.allowOperationalCdpSessionDetach(cdpSession);
|
|
2273
3319
|
const generation = tab.generation + 1;
|
|
2274
3320
|
const frameListener: TScreencastFrameListener = (event) => {
|
|
2275
3321
|
this.handleScreencastFrame(tab, cdpSession, generation, event);
|
|
@@ -2320,6 +3366,8 @@ export class LiveBrowserSession {
|
|
|
2320
3366
|
plugins.puppeteer.CDPSessionEvent.SessionDetached,
|
|
2321
3367
|
cdpSessionDetachedListener,
|
|
2322
3368
|
);
|
|
3369
|
+
await this.retireOutstandingFrames((frame) => frame.cdpSession === cdpSession);
|
|
3370
|
+
await this.waitForCdpFrameAcknowledgements(cdpSession);
|
|
2323
3371
|
if (!cdpSession.detached) {
|
|
2324
3372
|
try {
|
|
2325
3373
|
await cdpSession.detach();
|
|
@@ -2364,6 +3412,7 @@ export class LiveBrowserSession {
|
|
|
2364
3412
|
cdpSessionDetachedListener,
|
|
2365
3413
|
);
|
|
2366
3414
|
}
|
|
3415
|
+
await this.waitForCdpFrameAcknowledgements(cdpSession);
|
|
2367
3416
|
if (!cdpSession.detached) {
|
|
2368
3417
|
try {
|
|
2369
3418
|
await cdpSession.detach();
|
|
@@ -2408,12 +3457,13 @@ export class LiveBrowserSession {
|
|
|
2408
3457
|
const sequence = ++this.frameSequence;
|
|
2409
3458
|
const outstandingFrame: IOutstandingFrame = {
|
|
2410
3459
|
tabId: tab.id,
|
|
3460
|
+
sequence,
|
|
2411
3461
|
generation,
|
|
2412
3462
|
viewportRevision: this.viewportRevision,
|
|
2413
3463
|
cdpSessionId: event.sessionId,
|
|
2414
3464
|
cdpSession,
|
|
2415
3465
|
};
|
|
2416
|
-
while (this.outstandingFrames.size >= maxOutstandingFrames) {
|
|
3466
|
+
while (this.outstandingFrames.size >= this.maxOutstandingFrames) {
|
|
2417
3467
|
const oldestFrameEntry = this.outstandingFrames.entries().next().value as
|
|
2418
3468
|
| [number, IOutstandingFrame]
|
|
2419
3469
|
| undefined;
|
|
@@ -2449,31 +3499,40 @@ export class LiveBrowserSession {
|
|
|
2449
3499
|
this.emitEvent({ type: 'frame', frame });
|
|
2450
3500
|
}
|
|
2451
3501
|
|
|
2452
|
-
private
|
|
2453
|
-
if (frame.
|
|
2454
|
-
|
|
2455
|
-
frame,
|
|
2456
|
-
new Error('CDP session detached before frame acknowledgement'),
|
|
2457
|
-
);
|
|
2458
|
-
return false;
|
|
3502
|
+
private acknowledgeCdpFrame(frame: ICdpScreencastFrame): Promise<boolean> {
|
|
3503
|
+
if (frame.acknowledgementPromise) {
|
|
3504
|
+
return frame.acknowledgementPromise;
|
|
2459
3505
|
}
|
|
2460
|
-
|
|
3506
|
+
let resolveAcknowledgement!: (accepted: boolean) => void;
|
|
3507
|
+
const acknowledgementPromise = new Promise<boolean>((resolve) => {
|
|
3508
|
+
resolveAcknowledgement = resolve;
|
|
3509
|
+
});
|
|
3510
|
+
frame.acknowledgementPromise = acknowledgementPromise;
|
|
3511
|
+
this.cdpFramesBeingAcknowledged.add(frame);
|
|
3512
|
+
void (async (): Promise<boolean> => {
|
|
3513
|
+
if (frame.cdpSession.detached) {
|
|
3514
|
+
throw new Error('CDP session detached before frame acknowledgement');
|
|
3515
|
+
}
|
|
2461
3516
|
await frame.cdpSession.send('Page.screencastFrameAck', {
|
|
2462
3517
|
sessionId: frame.cdpSessionId,
|
|
2463
|
-
});
|
|
3518
|
+
}, { timeout: frameAcknowledgementTimeoutMs });
|
|
2464
3519
|
return true;
|
|
2465
|
-
}
|
|
3520
|
+
})().then(resolveAcknowledgement, (error) => {
|
|
2466
3521
|
this.handleFrameAcknowledgementFailure(frame, error);
|
|
2467
|
-
|
|
2468
|
-
}
|
|
3522
|
+
resolveAcknowledgement(false);
|
|
3523
|
+
});
|
|
3524
|
+
void acknowledgementPromise.then(() => {
|
|
3525
|
+
this.cdpFramesBeingAcknowledged.delete(frame);
|
|
3526
|
+
});
|
|
3527
|
+
return acknowledgementPromise;
|
|
2469
3528
|
}
|
|
2470
3529
|
|
|
2471
|
-
private acknowledgeCdpFrameInBackground(frame:
|
|
3530
|
+
private acknowledgeCdpFrameInBackground(frame: ICdpScreencastFrame): void {
|
|
2472
3531
|
void this.acknowledgeCdpFrame(frame);
|
|
2473
3532
|
}
|
|
2474
3533
|
|
|
2475
3534
|
private handleFrameAcknowledgementFailure(
|
|
2476
|
-
frame:
|
|
3535
|
+
frame: ICdpScreencastFrame,
|
|
2477
3536
|
error: unknown,
|
|
2478
3537
|
): void {
|
|
2479
3538
|
const tab = this.tabs.get(frame.tabId);
|
|
@@ -2513,6 +3572,15 @@ export class LiveBrowserSession {
|
|
|
2513
3572
|
await Promise.all(acknowledgements);
|
|
2514
3573
|
}
|
|
2515
3574
|
|
|
3575
|
+
private async waitForCdpFrameAcknowledgements(
|
|
3576
|
+
cdpSession?: plugins.puppeteer.CDPSession,
|
|
3577
|
+
): Promise<void> {
|
|
3578
|
+
const acknowledgements = [...this.cdpFramesBeingAcknowledged]
|
|
3579
|
+
.filter((frame) => !cdpSession || frame.cdpSession === cdpSession)
|
|
3580
|
+
.map((frame) => frame.acknowledgementPromise!);
|
|
3581
|
+
await Promise.all(acknowledgements);
|
|
3582
|
+
}
|
|
3583
|
+
|
|
2516
3584
|
private handlePossibleCdpDisconnection(
|
|
2517
3585
|
tab: IPrivateLiveBrowserTab,
|
|
2518
3586
|
cdpSession: plugins.puppeteer.CDPSession,
|
|
@@ -2971,5 +4039,13 @@ export class LiveBrowserSession {
|
|
|
2971
4039
|
if (options.everyNthFrame !== undefined) {
|
|
2972
4040
|
validateInteger(options.everyNthFrame, 'screencast.everyNthFrame', 1, 100);
|
|
2973
4041
|
}
|
|
4042
|
+
if (options.maxOutstandingFrames !== undefined) {
|
|
4043
|
+
validateInteger(
|
|
4044
|
+
options.maxOutstandingFrames,
|
|
4045
|
+
'screencast.maxOutstandingFrames',
|
|
4046
|
+
1,
|
|
4047
|
+
liveBrowserMaxOutstandingFrames,
|
|
4048
|
+
);
|
|
4049
|
+
}
|
|
2974
4050
|
}
|
|
2975
4051
|
}
|