Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/puppet-chrome/lib/Page.ts
1028 views
1
/**
2
* Copyright 2018 Google Inc. All rights reserved.
3
* Modifications copyright (c) Data Liberation Foundation Inc.
4
*
5
* Licensed under the Apache License, Version 2.0 (the "License");
6
* you may not use this file except in compliance with the License.
7
* You may obtain a copy of the License at
8
*
9
* http://www.apache.org/licenses/LICENSE-2.0
10
*
11
* Unless required by applicable law or agreed to in writing, software
12
* distributed under the License is distributed on an "AS IS" BASIS,
13
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
* See the License for the specific language governing permissions and
15
* limitations under the License.
16
*/
17
import EventSubscriber from '@secret-agent/commons/EventSubscriber';
18
import Protocol from 'devtools-protocol';
19
import { IPuppetPage, IPuppetPageEvents } from '@secret-agent/interfaces/IPuppetPage';
20
import { TypedEventEmitter } from '@secret-agent/commons/eventUtils';
21
import IRegisteredEventListener from '@secret-agent/interfaces/IRegisteredEventListener';
22
import { assert, createPromise } from '@secret-agent/commons/utils';
23
import { IBoundLog } from '@secret-agent/interfaces/ILog';
24
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
25
import IRect from '@secret-agent/interfaces/IRect';
26
import { DevtoolsSession } from './DevtoolsSession';
27
import { NetworkManager } from './NetworkManager';
28
import { Keyboard } from './Keyboard';
29
import Mouse from './Mouse';
30
import FramesManager from './FramesManager';
31
import { BrowserContext } from './BrowserContext';
32
import { Worker } from './Worker';
33
import ConsoleMessage from './ConsoleMessage';
34
import Frame from './Frame';
35
import ConsoleAPICalledEvent = Protocol.Runtime.ConsoleAPICalledEvent;
36
import ExceptionThrownEvent = Protocol.Runtime.ExceptionThrownEvent;
37
import WindowOpenEvent = Protocol.Page.WindowOpenEvent;
38
import TargetInfo = Protocol.Target.TargetInfo;
39
import JavascriptDialogOpeningEvent = Protocol.Page.JavascriptDialogOpeningEvent;
40
import FileChooserOpenedEvent = Protocol.Page.FileChooserOpenedEvent;
41
42
export class Page extends TypedEventEmitter<IPuppetPageEvents> implements IPuppetPage {
43
public keyboard: Keyboard;
44
public mouse: Mouse;
45
public workersById = new Map<string, Worker>();
46
public readonly browserContext: BrowserContext;
47
public readonly opener: Page | null;
48
public networkManager: NetworkManager;
49
public framesManager: FramesManager;
50
51
public popupInitializeFn?: (
52
page: IPuppetPage,
53
openParams: { url: string; windowName: string },
54
) => Promise<void>;
55
56
public devtoolsSession: DevtoolsSession;
57
public targetId: string;
58
public isClosed = false;
59
public readonly isReady: Promise<void>;
60
public windowOpenParams: Protocol.Page.WindowOpenEvent;
61
62
public get id(): string {
63
return this.targetId;
64
}
65
66
public get mainFrame(): Frame {
67
return this.framesManager.main;
68
}
69
70
public get frames(): Frame[] {
71
return this.framesManager.activeFrames;
72
}
73
74
public get workers(): Worker[] {
75
return [...this.workersById.values()];
76
}
77
78
protected readonly logger: IBoundLog;
79
private closePromise = createPromise();
80
private readonly eventSubscriber = new EventSubscriber();
81
82
constructor(
83
devtoolsSession: DevtoolsSession,
84
targetId: string,
85
browserContext: BrowserContext,
86
logger: IBoundLog,
87
opener: Page | null,
88
) {
89
super();
90
91
this.logger = logger.createChild(module, {
92
targetId,
93
});
94
this.logger.info('Page.created');
95
this.storeEventsWithoutListeners = true;
96
this.devtoolsSession = devtoolsSession;
97
this.targetId = targetId;
98
this.browserContext = browserContext;
99
this.keyboard = new Keyboard(devtoolsSession);
100
this.mouse = new Mouse(devtoolsSession, this.keyboard);
101
this.networkManager = new NetworkManager(
102
devtoolsSession,
103
this.logger,
104
this.browserContext.proxy,
105
);
106
this.framesManager = new FramesManager(devtoolsSession, this.logger);
107
this.opener = opener;
108
109
this.setEventsToLog([
110
'frame-created',
111
'websocket-frame',
112
'websocket-handshake',
113
'navigation-response',
114
'worker',
115
]);
116
117
this.framesManager.addEventEmitter(this, ['frame-created']);
118
119
this.networkManager.addEventEmitter(this, [
120
'navigation-response',
121
'websocket-frame',
122
'websocket-handshake',
123
'resource-will-be-requested',
124
'resource-was-requested',
125
'resource-loaded',
126
'resource-failed',
127
]);
128
129
this.devtoolsSession.once('disconnected', this.emit.bind(this, 'close'));
130
131
const events = this.eventSubscriber;
132
133
events.on(devtoolsSession, 'Inspector.targetCrashed', this.onTargetCrashed.bind(this));
134
events.on(devtoolsSession, 'Runtime.exceptionThrown', this.onRuntimeException.bind(this));
135
events.on(devtoolsSession, 'Runtime.consoleAPICalled', this.onRuntimeConsole.bind(this));
136
events.on(devtoolsSession, 'Target.attachedToTarget', this.onAttachedToTarget.bind(this));
137
events.on(
138
devtoolsSession,
139
'Page.javascriptDialogOpening',
140
this.onJavascriptDialogOpening.bind(this),
141
);
142
events.on(devtoolsSession, 'Page.fileChooserOpened', this.onFileChooserOpened.bind(this));
143
events.on(devtoolsSession, 'Page.windowOpen', this.onWindowOpen.bind(this));
144
145
this.isReady = this.initialize().catch(error => {
146
this.logger.error('Page.initializationError', {
147
error,
148
});
149
throw error;
150
});
151
}
152
153
async setNetworkRequestInterceptor(
154
networkRequestsFn: (
155
request: Protocol.Fetch.RequestPausedEvent,
156
) => Promise<Protocol.Fetch.FulfillRequestRequest>,
157
): Promise<void> {
158
return await this.networkManager.setNetworkInterceptor(networkRequestsFn, true);
159
}
160
161
addNewDocumentScript(script: string, isolatedEnvironment: boolean): Promise<void> {
162
return this.framesManager.addNewDocumentScript(script, isolatedEnvironment);
163
}
164
165
addPageCallback(
166
name: string,
167
onCallback: (payload: any, frameId: string) => any,
168
): Promise<IRegisteredEventListener> {
169
return this.framesManager.addPageCallback(name, (payload, frameId) => {
170
if (onCallback) onCallback(payload, frameId);
171
172
this.emit('page-callback-triggered', {
173
name,
174
payload,
175
frameId,
176
});
177
});
178
}
179
180
async getIndexedDbDatabaseNames(): Promise<
181
{ frameId: string; origin: string; databases: string[] }[]
182
> {
183
const dbs: { frameId: string; origin: string; databases: string[] }[] = [];
184
for (const { origin, frameId } of this.framesManager.getSecurityOrigins()) {
185
try {
186
const { databaseNames } = await this.devtoolsSession.send(
187
'IndexedDB.requestDatabaseNames',
188
{
189
securityOrigin: origin,
190
},
191
);
192
dbs.push({ origin, frameId, databases: databaseNames });
193
} catch (err) {
194
// can throw if document not found in page
195
}
196
}
197
return dbs;
198
}
199
200
async setJavaScriptEnabled(enabled: boolean): Promise<void> {
201
await this.devtoolsSession.send('Emulation.setScriptExecutionDisabled', {
202
value: !enabled,
203
});
204
}
205
206
evaluate<T>(expression: string): Promise<T> {
207
return this.mainFrame.evaluate<T>(expression, false);
208
}
209
210
async navigate(url: string, options: { referrer?: string } = {}): Promise<{ loaderId: string }> {
211
const navigationResponse = await this.devtoolsSession.send('Page.navigate', {
212
url,
213
referrer: options.referrer,
214
frameId: this.mainFrame.id,
215
});
216
if (navigationResponse.errorText) throw new Error(navigationResponse.errorText);
217
await this.framesManager.waitForFrame(navigationResponse, url, true);
218
return { loaderId: navigationResponse.loaderId };
219
}
220
221
dismissDialog(accept: boolean, promptText?: string): Promise<void> {
222
return this.devtoolsSession.send('Page.handleJavaScriptDialog', {
223
accept,
224
promptText,
225
});
226
}
227
228
goBack(): Promise<string> {
229
return this.navigateToHistory(-1);
230
}
231
232
goForward(): Promise<string> {
233
return this.navigateToHistory(+1);
234
}
235
236
reload(): Promise<void> {
237
return this.devtoolsSession.send('Page.reload');
238
}
239
240
async bringToFront(): Promise<void> {
241
await this.devtoolsSession.send('Page.bringToFront');
242
}
243
244
async screenshot(
245
format: 'jpeg' | 'png' = 'jpeg',
246
clipRect?: IRect & { scale: number },
247
quality = 100,
248
): Promise<Buffer> {
249
assert(
250
quality >= 0 && quality <= 100,
251
`Expected options.quality to be between 0 and 100 (inclusive), got ${quality}`,
252
);
253
await this.devtoolsSession.send('Target.activateTarget', {
254
targetId: this.targetId,
255
});
256
257
const clip: Protocol.Page.Viewport = clipRect;
258
259
if (clip) {
260
clip.x = Math.round(clip.x);
261
clip.y = Math.round(clip.y);
262
clip.width = Math.round(clip.width);
263
clip.height = Math.round(clip.height);
264
}
265
const result = await this.devtoolsSession.send('Page.captureScreenshot', {
266
format,
267
quality,
268
clip,
269
captureBeyondViewport: true, // added in chrome 87
270
} as Protocol.Page.CaptureScreenshotRequest);
271
272
return Buffer.from(result.data, 'base64');
273
}
274
275
onWorkerAttached(
276
devtoolsSession: DevtoolsSession,
277
targetInfo: TargetInfo,
278
): Promise<Error | void> {
279
const targetId = targetInfo.targetId;
280
281
this.browserContext.beforeWorkerAttached(devtoolsSession, targetId, this.targetId);
282
283
const worker = new Worker(
284
this.browserContext,
285
this.networkManager,
286
devtoolsSession,
287
this.logger,
288
targetInfo,
289
);
290
if (worker.type !== 'shared_worker') this.workersById.set(targetId, worker);
291
this.browserContext.onWorkerAttached(worker);
292
293
worker.on('console', this.emit.bind(this, 'console'));
294
worker.on('page-error', this.emit.bind(this, 'page-error'));
295
worker.on('close', () => this.workersById.delete(targetId));
296
297
this.emit('worker', { worker });
298
return worker.isReady;
299
}
300
301
async close(timeoutMs = 5e3): Promise<void> {
302
if (this.devtoolsSession.isConnected() && !this.isClosed) {
303
// trigger beforeUnload
304
try {
305
await this.devtoolsSession.send('Page.close');
306
} catch (err) {
307
if (!err.message.includes('Target closed') && !(err instanceof CanceledPromiseError)) {
308
throw err;
309
}
310
}
311
}
312
const timeout = setTimeout(() => this.didClose(), timeoutMs);
313
await this.closePromise.promise;
314
clearTimeout(timeout);
315
}
316
317
onTargetKilled(errorCode: number): void {
318
this.emit('crashed', {
319
error: new Error(`Page crashed - killed by Chrome with code ${errorCode}`),
320
fatal: true,
321
});
322
this.didClose();
323
}
324
325
didClose(closeError?: Error): void {
326
this.isClosed = true;
327
try {
328
this.framesManager.close(closeError);
329
this.networkManager.close();
330
this.eventSubscriber.close();
331
this.cancelPendingEvents('Page closed', ['close']);
332
for (const worker of this.workersById.values()) {
333
worker.close();
334
}
335
} catch (error) {
336
this.logger.error('Page.closeWorkersError', {
337
error,
338
});
339
} finally {
340
this.closePromise.resolve();
341
this.emit('close');
342
}
343
}
344
345
private async navigateToHistory(delta: number): Promise<string> {
346
const history = await this.devtoolsSession.send('Page.getNavigationHistory');
347
const entry = history.entries[history.currentIndex + delta];
348
if (!entry) return null;
349
await Promise.all([
350
this.devtoolsSession.send('Page.navigateToHistoryEntry', { entryId: entry.id }),
351
this.mainFrame.waitOn('frame-navigated'),
352
]);
353
return entry.url;
354
}
355
356
private async initialize(): Promise<void> {
357
const promises = [
358
this.networkManager.initialize().catch(err => err),
359
this.framesManager.initialize().catch(err => err),
360
this.devtoolsSession
361
.send('Target.setAutoAttach', {
362
autoAttach: true,
363
waitForDebuggerOnStart: true,
364
flatten: true,
365
})
366
.catch(err => err),
367
this.browserContext.initializePage(this),
368
this.devtoolsSession
369
.send('Page.setInterceptFileChooserDialog', { enabled: true })
370
.catch(err => err),
371
this.devtoolsSession.send('Runtime.runIfWaitingForDebugger').catch(err => err),
372
];
373
374
for (const error of await Promise.all(promises)) {
375
if (error && error instanceof Error) throw error;
376
}
377
378
if (this.opener && this.opener.popupInitializeFn) {
379
this.logger.stats('Popup triggered', {
380
targetId: this.targetId,
381
opener: this.opener.targetId,
382
});
383
await this.opener.isReady;
384
if (this.opener.isClosed) {
385
this.logger.stats('Popup canceled', {
386
targetId: this.targetId,
387
});
388
return;
389
}
390
if (this.mainFrame.isDefaultUrl) {
391
// if we're on the default page, wait for a loader to be created before telling the page it's ready
392
await this.mainFrame.waitOn('frame-loader-created', null, 2e3).catch(() => null);
393
if (this.isClosed) return;
394
}
395
await this.opener.popupInitializeFn(this, this.opener.windowOpenParams);
396
this.logger.stats('Popup initialized', {
397
targetId: this.targetId,
398
windowOpenParams: this.opener.windowOpenParams,
399
});
400
}
401
}
402
403
private onAttachedToTarget(event: Protocol.Target.AttachedToTargetEvent): Promise<any> {
404
const { sessionId, targetInfo, waitingForDebugger } = event;
405
406
const devtoolsSession = this.devtoolsSession.connection.getSession(sessionId);
407
if (
408
targetInfo.type === 'service_worker' ||
409
targetInfo.type === 'shared_worker' ||
410
targetInfo.type === 'worker'
411
) {
412
return this.onWorkerAttached(devtoolsSession, targetInfo);
413
}
414
415
if (waitingForDebugger) {
416
return devtoolsSession
417
.send('Runtime.runIfWaitingForDebugger')
418
.catch(error => {
419
this.logger.error('Runtime.runIfWaitingForDebugger.Error', {
420
error,
421
devtoolsSessionId: sessionId,
422
});
423
})
424
.then(() =>
425
// detach from page session
426
this.devtoolsSession.send('Target.detachFromTarget', { sessionId }),
427
)
428
.catch(error => {
429
this.logger.error('Target.detachFromTarget', {
430
error,
431
devtoolsSessionId: sessionId,
432
});
433
});
434
}
435
}
436
437
private onRuntimeException(msg: ExceptionThrownEvent): void {
438
const error = ConsoleMessage.exceptionToError(msg.exceptionDetails);
439
const frameId = this.framesManager.getFrameIdForExecutionContext(
440
msg.exceptionDetails.executionContextId,
441
);
442
this.emit('page-error', {
443
frameId,
444
error,
445
});
446
}
447
448
private onRuntimeConsole(event: ConsoleAPICalledEvent): void {
449
const message = ConsoleMessage.create(this.devtoolsSession, event);
450
const frameId = this.framesManager.getFrameIdForExecutionContext(event.executionContextId);
451
452
this.emit('console', {
453
frameId,
454
...message,
455
});
456
}
457
458
private onTargetCrashed(): void {
459
this.emit('crashed', { error: new Error('Target Crashed') });
460
}
461
462
private onWindowOpen(event: WindowOpenEvent): void {
463
this.windowOpenParams = event;
464
}
465
466
private onJavascriptDialogOpening(dialog: JavascriptDialogOpeningEvent): void {
467
this.emit('dialog-opening', { dialog });
468
}
469
470
private onFileChooserOpened(event: FileChooserOpenedEvent): void {
471
this.framesManager.framesById
472
.get(event.frameId)
473
.resolveNodeId(event.backendNodeId)
474
.then(objectId =>
475
this.emit('filechooser', {
476
objectId,
477
frameId: event.frameId,
478
selectMultiple: event.mode === 'selectMultiple',
479
}),
480
)
481
.catch(() => null);
482
}
483
}
484
485