@push.rocks/smartpuppeteer 2.2.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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/smartpuppeteer.classes.livebrowsersession.d.ts +31 -1
- package/dist_ts/smartpuppeteer.classes.livebrowsersession.js +798 -16
- 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 +23 -0
- package/dist_ts/smartpuppeteer.plugins.d.ts +4 -2
- package/dist_ts/smartpuppeteer.plugins.js +5 -3
- package/package.json +3 -3
- package/readme.md +15 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/smartpuppeteer.classes.livebrowsersession.ts +1016 -15
- package/ts/smartpuppeteer.helpers.process.ts +169 -0
- package/ts/smartpuppeteer.interfaces.livebrowser.ts +27 -0
- package/ts/smartpuppeteer.plugins.ts +4 -2
|
@@ -1,4 +1,13 @@
|
|
|
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,
|
|
@@ -18,11 +27,14 @@ import type {
|
|
|
18
27
|
ILiveBrowserObserveOptions,
|
|
19
28
|
ILiveBrowserOperationOptions,
|
|
20
29
|
ILiveBrowserPressOptions,
|
|
30
|
+
ILiveBrowserProcessState,
|
|
21
31
|
ILiveBrowserSessionOptions,
|
|
22
32
|
ILiveBrowserSnapshot,
|
|
23
33
|
ILiveBrowserSnapshotOptions,
|
|
24
34
|
ILiveBrowserState,
|
|
25
35
|
ILiveBrowserTabState,
|
|
36
|
+
ILiveBrowserTerminationOptions,
|
|
37
|
+
ILiveBrowserTerminationResult,
|
|
26
38
|
ILiveBrowserViewport,
|
|
27
39
|
ILiveBrowserWheelInput,
|
|
28
40
|
TLiveBrowserEvent,
|
|
@@ -60,10 +72,52 @@ const maxEvaluationObjectKeys = 10000;
|
|
|
60
72
|
const evaluationBootstrapKey = '__smartpuppeteerEvaluate';
|
|
61
73
|
const evaluationCancelKey = '__smartpuppeteerCancel';
|
|
62
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']);
|
|
63
87
|
|
|
64
88
|
type TScreencastFrameEvent = plugins.puppeteer.Protocol.Page.ScreencastFrameEvent;
|
|
65
89
|
type TScreencastFrameListener = (event: TScreencastFrameEvent) => void;
|
|
66
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
|
+
}
|
|
67
121
|
|
|
68
122
|
interface IPrivateLiveBrowserTab {
|
|
69
123
|
id: string;
|
|
@@ -318,6 +372,20 @@ export class LiveBrowserSession {
|
|
|
318
372
|
private browserContext?: plugins.puppeteer.BrowserContext;
|
|
319
373
|
private browserLifetimeController?: AbortController;
|
|
320
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>;
|
|
321
389
|
private readonly operationQueue: IQueuedOperation[] = [];
|
|
322
390
|
private activeOperation?: IQueuedOperation;
|
|
323
391
|
private operationRunning = false;
|
|
@@ -351,8 +419,31 @@ export class LiveBrowserSession {
|
|
|
351
419
|
throw new Error('LiveBrowserSession owns launch cancellation; launchOptions.signal is unsupported');
|
|
352
420
|
}
|
|
353
421
|
validateOptionalBoolean(optionsArg.allowEvaluation, 'allowEvaluation');
|
|
354
|
-
for (const
|
|
355
|
-
|
|
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
|
+
);
|
|
356
447
|
}
|
|
357
448
|
const launchViewport = optionsArg.launchOptions?.defaultViewport;
|
|
358
449
|
const viewport = normalizeViewport(
|
|
@@ -377,7 +468,12 @@ export class LiveBrowserSession {
|
|
|
377
468
|
},
|
|
378
469
|
viewport,
|
|
379
470
|
screencast: optionsArg.screencast ? { ...optionsArg.screencast } : undefined,
|
|
380
|
-
security: optionsArg.security
|
|
471
|
+
security: optionsArg.security
|
|
472
|
+
? {
|
|
473
|
+
...optionsArg.security,
|
|
474
|
+
proxyCredentials: proxyCredentials ? { ...proxyCredentials } : undefined,
|
|
475
|
+
}
|
|
476
|
+
: undefined,
|
|
381
477
|
};
|
|
382
478
|
this.viewport = { ...viewport };
|
|
383
479
|
this.validateScreencastOptions();
|
|
@@ -404,12 +500,86 @@ export class LiveBrowserSession {
|
|
|
404
500
|
};
|
|
405
501
|
}
|
|
406
502
|
|
|
407
|
-
public
|
|
408
|
-
|
|
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) => {
|
|
409
571
|
if (this.status === 'running' || this.status === 'starting') {
|
|
410
572
|
return;
|
|
411
573
|
}
|
|
412
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
|
+
|
|
413
583
|
this.normalStopRequested = false;
|
|
414
584
|
this.lastError = undefined;
|
|
415
585
|
this.viewportRevision = 1;
|
|
@@ -445,10 +615,56 @@ export class LiveBrowserSession {
|
|
|
445
615
|
} finally {
|
|
446
616
|
signal.removeEventListener('abort', abortBrowserLaunch);
|
|
447
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());
|
|
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;
|
|
448
649
|
if (signal.aborted) {
|
|
449
650
|
throw signal.reason;
|
|
450
651
|
}
|
|
451
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);
|
|
452
668
|
await this.configureBrowserSecurity(this.browser);
|
|
453
669
|
this.browserDisconnectedListener = () => {
|
|
454
670
|
if (this.normalStopRequested || this.status === 'stopped' || this.status === 'stopping') {
|
|
@@ -497,7 +713,14 @@ export class LiveBrowserSession {
|
|
|
497
713
|
}
|
|
498
714
|
} catch (error) {
|
|
499
715
|
if (signal.aborted || this.normalStopRequested) {
|
|
500
|
-
|
|
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
|
+
}
|
|
501
724
|
throw error;
|
|
502
725
|
}
|
|
503
726
|
const startError: ILiveBrowserError = {
|
|
@@ -506,10 +729,20 @@ export class LiveBrowserSession {
|
|
|
506
729
|
fatal: true,
|
|
507
730
|
};
|
|
508
731
|
this.emitError(startError);
|
|
509
|
-
|
|
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
|
+
}
|
|
510
740
|
throw error;
|
|
511
741
|
}
|
|
512
742
|
}, operationOptions);
|
|
743
|
+
return startPromise.finally(() => {
|
|
744
|
+
this.startRequestCount -= 1;
|
|
745
|
+
});
|
|
513
746
|
}
|
|
514
747
|
|
|
515
748
|
public async stop(): Promise<void> {
|
|
@@ -1070,6 +1303,7 @@ export class LiveBrowserSession {
|
|
|
1070
1303
|
return this.enqueuePublicOperation(async (signal) => {
|
|
1071
1304
|
const tab = this.resolveActionTab(optionsArg.tabId);
|
|
1072
1305
|
const cdpSession = await tab.page.createCDPSession();
|
|
1306
|
+
this.allowOperationalCdpSessionDetach(cdpSession);
|
|
1073
1307
|
let executionContextId: number | undefined;
|
|
1074
1308
|
let cancellationPromise: Promise<void> | undefined;
|
|
1075
1309
|
let terminationPromise: Promise<void> | undefined;
|
|
@@ -1346,7 +1580,25 @@ export class LiveBrowserSession {
|
|
|
1346
1580
|
return Promise.reject(new Error('LiveBrowserSession operation queue is full'));
|
|
1347
1581
|
}
|
|
1348
1582
|
this.admittedPublicOperations += 1;
|
|
1349
|
-
return this.enqueueQueuedOperation('public',
|
|
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
|
+
}
|
|
1350
1602
|
}
|
|
1351
1603
|
|
|
1352
1604
|
private enqueueInternalOperation(
|
|
@@ -1509,7 +1761,8 @@ export class LiveBrowserSession {
|
|
|
1509
1761
|
}
|
|
1510
1762
|
|
|
1511
1763
|
private requestShutdown(error?: ILiveBrowserError): Promise<void> {
|
|
1512
|
-
if (this.status === 'stopped') {
|
|
1764
|
+
if (this.status === 'stopped' && !this.operationRunning && this.startRequestCount === 0) {
|
|
1765
|
+
this.normalStopRequested = false;
|
|
1513
1766
|
return Promise.resolve();
|
|
1514
1767
|
}
|
|
1515
1768
|
if (this.shutdownPromise) {
|
|
@@ -1622,6 +1875,248 @@ export class LiveBrowserSession {
|
|
|
1622
1875
|
};
|
|
1623
1876
|
}
|
|
1624
1877
|
|
|
1878
|
+
private async releaseExitedOwnedProcess(): Promise<void> {
|
|
1879
|
+
const ownedProcess = this.ownedBrowserProcess;
|
|
1880
|
+
if (!ownedProcess) {
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
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
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
if (process.platform === 'linux' && !ownedProcess.identity) {
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
if (ownedProcess.identity) {
|
|
1899
|
+
const members = await listOwnedProcessGroupMembers(ownedProcess.identity);
|
|
1900
|
+
if (members.length > 0) {
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
this.ownedBrowserProcess = undefined;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
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;
|
|
1941
|
+
}
|
|
1942
|
+
return result;
|
|
1943
|
+
};
|
|
1944
|
+
const isConfirmedDead = async (
|
|
1945
|
+
ownedProcess: IOwnedBrowserProcess | undefined,
|
|
1946
|
+
): Promise<boolean> => {
|
|
1947
|
+
if (!ownedProcess) {
|
|
1948
|
+
return true;
|
|
1949
|
+
}
|
|
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
|
+
};
|
|
1968
|
+
|
|
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)]);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
|
|
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
|
+
}
|
|
2006
|
+
}
|
|
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
|
+
|
|
1625
2120
|
private async stopInternal(error?: ILiveBrowserError): Promise<void> {
|
|
1626
2121
|
if (this.status === 'stopped') {
|
|
1627
2122
|
return;
|
|
@@ -1636,12 +2131,21 @@ export class LiveBrowserSession {
|
|
|
1636
2131
|
}
|
|
1637
2132
|
|
|
1638
2133
|
const browser = this.browser;
|
|
2134
|
+
if (browser && this.browserTargetCreatedListener) {
|
|
2135
|
+
browser.off('targetcreated', this.browserTargetCreatedListener);
|
|
2136
|
+
}
|
|
2137
|
+
this.browserTargetCreatedListener = undefined;
|
|
1639
2138
|
if (browser && this.browserDisconnectedListener) {
|
|
1640
2139
|
browser.off('disconnected', this.browserDisconnectedListener);
|
|
1641
2140
|
}
|
|
1642
2141
|
this.browserDisconnectedListener = undefined;
|
|
1643
2142
|
|
|
1644
2143
|
const shutdownErrors: unknown[] = [];
|
|
2144
|
+
try {
|
|
2145
|
+
await this.teardownProxySecurity();
|
|
2146
|
+
} catch (cleanupError) {
|
|
2147
|
+
shutdownErrors.push(cleanupError);
|
|
2148
|
+
}
|
|
1645
2149
|
for (const tab of [...this.tabs.values()]) {
|
|
1646
2150
|
tab.closing = true;
|
|
1647
2151
|
const securityCdpSession = tab.securityCdpSession;
|
|
@@ -1675,6 +2179,14 @@ export class LiveBrowserSession {
|
|
|
1675
2179
|
await browser.close();
|
|
1676
2180
|
} catch (closeError) {
|
|
1677
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
|
+
}
|
|
1678
2190
|
}
|
|
1679
2191
|
}
|
|
1680
2192
|
|
|
@@ -1689,7 +2201,7 @@ export class LiveBrowserSession {
|
|
|
1689
2201
|
this.activeTabId = null;
|
|
1690
2202
|
this.status = 'stopped';
|
|
1691
2203
|
this.emitState();
|
|
1692
|
-
if (shutdownErrors.length > 0
|
|
2204
|
+
if (shutdownErrors.length > 0) {
|
|
1693
2205
|
throw new AggregateError(shutdownErrors, 'LiveBrowserSession shutdown was incomplete');
|
|
1694
2206
|
}
|
|
1695
2207
|
}
|
|
@@ -1697,17 +2209,442 @@ export class LiveBrowserSession {
|
|
|
1697
2209
|
private async configureBrowserSecurity(
|
|
1698
2210
|
browser: plugins.puppeteer.Browser,
|
|
1699
2211
|
): Promise<void> {
|
|
1700
|
-
|
|
2212
|
+
const denyPermissions = this.options.security?.denyPermissions;
|
|
2213
|
+
const proxyCredentials = this.options.security?.proxyCredentials;
|
|
2214
|
+
if (!denyPermissions && !proxyCredentials) {
|
|
1701
2215
|
return;
|
|
1702
2216
|
}
|
|
1703
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;
|
|
1704
2238
|
try {
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
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.
|
|
1709
2642
|
}
|
|
1710
2643
|
}
|
|
2644
|
+
while (this.proxySecurityOperations.size > 0) {
|
|
2645
|
+
await Promise.allSettled([...this.proxySecurityOperations]);
|
|
2646
|
+
}
|
|
2647
|
+
this.proxySecurityGeneration = 0;
|
|
1711
2648
|
}
|
|
1712
2649
|
|
|
1713
2650
|
private requireBrowserContext(): plugins.puppeteer.BrowserContext {
|
|
@@ -1776,6 +2713,68 @@ export class LiveBrowserSession {
|
|
|
1776
2713
|
}
|
|
1777
2714
|
}
|
|
1778
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
|
+
|
|
1779
2778
|
private async registerPage(page: plugins.puppeteer.Page): Promise<IPrivateLiveBrowserTab> {
|
|
1780
2779
|
const existingTabId = this.tabIdsByPage.get(page);
|
|
1781
2780
|
if (existingTabId) {
|
|
@@ -1807,6 +2806,7 @@ export class LiveBrowserSession {
|
|
|
1807
2806
|
try {
|
|
1808
2807
|
if (this.options.security?.denyFileChoosers) {
|
|
1809
2808
|
const securityCdpSession = await page.createCDPSession();
|
|
2809
|
+
this.allowOperationalCdpSessionDetach(securityCdpSession);
|
|
1810
2810
|
tab.securityCdpSession = securityCdpSession;
|
|
1811
2811
|
await securityCdpSession.send('Page.enable', {
|
|
1812
2812
|
enableFileChooserOpenedEvent: true,
|
|
@@ -2270,6 +3270,7 @@ export class LiveBrowserSession {
|
|
|
2270
3270
|
await this.ensureTabViewport(tab);
|
|
2271
3271
|
|
|
2272
3272
|
const cdpSession = await tab.page.createCDPSession();
|
|
3273
|
+
this.allowOperationalCdpSessionDetach(cdpSession);
|
|
2273
3274
|
const generation = tab.generation + 1;
|
|
2274
3275
|
const frameListener: TScreencastFrameListener = (event) => {
|
|
2275
3276
|
this.handleScreencastFrame(tab, cdpSession, generation, event);
|