Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/puppet-chrome/lib/Frame.ts
1028 views
1
import {
2
ILifecycleEvents,
3
IPuppetFrame,
4
IPuppetFrameEvents,
5
} from '@secret-agent/interfaces/IPuppetFrame';
6
import { URL } from 'url';
7
import Protocol from 'devtools-protocol';
8
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
9
import { TypedEventEmitter } from '@secret-agent/commons/eventUtils';
10
import { NavigationReason } from '@secret-agent/interfaces/INavigation';
11
import { IBoundLog } from '@secret-agent/interfaces/ILog';
12
import Resolvable from '@secret-agent/commons/Resolvable';
13
import ProtocolError from './ProtocolError';
14
import { DevtoolsSession } from './DevtoolsSession';
15
import ConsoleMessage from './ConsoleMessage';
16
import { DEFAULT_PAGE, ISOLATED_WORLD } from './FramesManager';
17
import { NavigationLoader } from './NavigationLoader';
18
import PageFrame = Protocol.Page.Frame;
19
20
const ContextNotFoundCode = -32000;
21
const InPageNavigationLoaderPrefix = 'inpage';
22
23
export default class Frame extends TypedEventEmitter<IPuppetFrameEvents> implements IPuppetFrame {
24
public get id(): string {
25
return this.internalFrame.id;
26
}
27
28
public get name(): string {
29
return this.internalFrame.name ?? '';
30
}
31
32
public get parentId(): string {
33
return this.internalFrame.parentId;
34
}
35
36
public url: string;
37
38
public get isDefaultUrl(): boolean {
39
return !this.url || this.url === ':' || this.url === DEFAULT_PAGE;
40
}
41
42
public get securityOrigin(): string {
43
if (!this.activeLoader?.isNavigationComplete || this.isDefaultUrl) return '';
44
let origin = this.internalFrame.securityOrigin;
45
if (!origin || origin === '://') {
46
this.internalFrame.securityOrigin = new URL(this.url).origin;
47
origin = this.internalFrame.securityOrigin;
48
}
49
return origin;
50
}
51
52
public navigationReason?: string;
53
54
public disposition?: string;
55
56
public get isAttached(): boolean {
57
return this.checkIfAttached();
58
}
59
60
public get activeLoader(): NavigationLoader {
61
return this.navigationLoadersById[this.activeLoaderId];
62
}
63
64
public activeLoaderId: string;
65
public navigationLoadersById: { [loaderId: string]: NavigationLoader } = {};
66
67
protected readonly logger: IBoundLog;
68
private isolatedWorldElementObjectId?: string;
69
private readonly parentFrame: Frame | null;
70
private readonly devtoolsSession: DevtoolsSession;
71
72
private defaultLoaderId: string;
73
private startedLoaderId: string;
74
75
private defaultContextId: number;
76
private isolatedContextId: number;
77
private readonly activeContextIds: Set<number>;
78
private internalFrame: PageFrame;
79
private closedWithError: Error;
80
private defaultContextCreated: Resolvable<void>;
81
private readonly checkIfAttached: () => boolean;
82
private inPageCounter = 0;
83
84
constructor(
85
internalFrame: PageFrame,
86
activeContextIds: Set<number>,
87
devtoolsSession: DevtoolsSession,
88
logger: IBoundLog,
89
checkIfAttached: () => boolean,
90
parentFrame: Frame | null,
91
) {
92
super();
93
this.activeContextIds = activeContextIds;
94
this.devtoolsSession = devtoolsSession;
95
this.logger = logger.createChild(module);
96
this.parentFrame = parentFrame;
97
this.checkIfAttached = checkIfAttached;
98
this.setEventsToLog(['frame-requested-navigation', 'frame-navigated', 'frame-lifecycle']);
99
this.storeEventsWithoutListeners = true;
100
this.onAttached(internalFrame);
101
}
102
103
public close(error: Error) {
104
this.cancelPendingEvents('Frame closed');
105
error ??= new CanceledPromiseError('Frame closed');
106
this.activeLoader.setNavigationResult(error);
107
this.defaultContextCreated?.reject(error);
108
this.closedWithError = error;
109
}
110
111
public async evaluate<T>(
112
expression: string,
113
isolateFromWebPageEnvironment?: boolean,
114
options?: { shouldAwaitExpression?: boolean; retriesWaitingForLoad?: number },
115
): Promise<T> {
116
if (this.closedWithError) throw this.closedWithError;
117
const startUrl = this.url;
118
const startOrigin = this.securityOrigin;
119
const contextId = await this.waitForActiveContextId(isolateFromWebPageEnvironment);
120
try {
121
const result: Protocol.Runtime.EvaluateResponse = await this.devtoolsSession.send(
122
'Runtime.evaluate',
123
{
124
expression,
125
contextId,
126
returnByValue: true,
127
awaitPromise: options?.shouldAwaitExpression ?? true,
128
},
129
this,
130
);
131
132
if (result.exceptionDetails) {
133
throw ConsoleMessage.exceptionToError(result.exceptionDetails);
134
}
135
136
const remote = result.result;
137
if (remote.objectId) this.devtoolsSession.disposeRemoteObject(remote);
138
return remote.value as T;
139
} catch (err) {
140
let retries = options?.retriesWaitingForLoad ?? 0;
141
// if we had a context id from a blank page, try again
142
if (
143
(!startOrigin || this.url !== startUrl) &&
144
this.getActiveContextId(isolateFromWebPageEnvironment) !== contextId
145
) {
146
retries += 1;
147
}
148
const isNotFoundError =
149
err.code === ContextNotFoundCode ||
150
(err as ProtocolError).remoteError?.code === ContextNotFoundCode;
151
if (isNotFoundError) {
152
if (retries > 0) {
153
// Cannot find context with specified id (ie, could be reloading or unloading)
154
return this.evaluate(expression, isolateFromWebPageEnvironment, {
155
shouldAwaitExpression: options?.shouldAwaitExpression,
156
retriesWaitingForLoad: retries - 1,
157
});
158
}
159
throw new CanceledPromiseError('The page context to evaluate javascript was not found');
160
}
161
throw err;
162
}
163
}
164
165
public async waitForLifecycleEvent(
166
event: keyof ILifecycleEvents = 'load',
167
loaderId?: string,
168
timeoutMs = 30e3,
169
): Promise<void> {
170
event ??= 'load';
171
timeoutMs ??= 30e3;
172
await this.waitForLoader(loaderId, timeoutMs);
173
const loader = this.navigationLoadersById[loaderId ?? this.activeLoaderId];
174
if (loader.lifecycle[event]) return;
175
await this.waitOn(
176
'frame-lifecycle',
177
x => {
178
if (loaderId && x.loader.id !== loaderId) return false;
179
return x.name === event;
180
},
181
timeoutMs,
182
);
183
}
184
185
public async setFileInputFiles(objectId: string, files: string[]): Promise<void> {
186
await this.devtoolsSession.send('DOM.setFileInputFiles', {
187
objectId,
188
files,
189
});
190
}
191
192
public async evaluateOnNode<T>(nodeId: string, expression: string): Promise<T> {
193
if (this.closedWithError) throw this.closedWithError;
194
try {
195
const result = await this.devtoolsSession.send('Runtime.callFunctionOn', {
196
functionDeclaration: `function executeRemoteFn() {
197
return ${expression};
198
}`,
199
returnByValue: true,
200
objectId: nodeId,
201
});
202
if (result.exceptionDetails) {
203
throw ConsoleMessage.exceptionToError(result.exceptionDetails);
204
}
205
206
const remote = result.result;
207
if (remote.objectId) this.devtoolsSession.disposeRemoteObject(remote);
208
return remote.value as T;
209
} catch (err) {
210
if (err instanceof CanceledPromiseError) return;
211
throw err;
212
}
213
}
214
215
public async getFrameElementNodeId(): Promise<string> {
216
try {
217
if (!this.parentFrame || this.isolatedWorldElementObjectId)
218
return this.isolatedWorldElementObjectId;
219
const owner = await this.devtoolsSession.send('DOM.getFrameOwner', { frameId: this.id });
220
this.isolatedWorldElementObjectId = await this.parentFrame.resolveNodeId(owner.backendNodeId);
221
// don't dispose... will cleanup frame
222
return this.isolatedWorldElementObjectId;
223
} catch (error) {
224
// ignore errors looking this up
225
this.logger.info('Failed to lookup isolated node', {
226
frameId: this.id,
227
error,
228
});
229
}
230
}
231
232
public async resolveNodeId(backendNodeId: number): Promise<string> {
233
const result = await this.devtoolsSession.send('DOM.resolveNode', {
234
backendNodeId,
235
executionContextId: this.getActiveContextId(true),
236
});
237
return result.object.objectId;
238
}
239
240
/////// NAVIGATION ///////////////////////////////////////////////////////////////////////////////////////////////////
241
242
public initiateNavigation(url: string, loaderId: string): void {
243
// chain current listeners to new promise
244
this.setLoader(loaderId, url);
245
}
246
247
public requestedNavigation(url: string, reason: NavigationReason, disposition: string): void {
248
this.navigationReason = reason;
249
this.disposition = disposition;
250
251
this.emit('frame-requested-navigation', { frame: this, url, reason });
252
}
253
254
public onAttached(internalFrame: PageFrame): void {
255
this.internalFrame = internalFrame;
256
this.updateUrl();
257
if (!internalFrame.loaderId) return;
258
259
// if we this is the first loader and url is default, this is the first loader
260
if (
261
this.isDefaultUrl &&
262
!this.defaultLoaderId &&
263
Object.keys(this.navigationLoadersById).length === 0
264
) {
265
this.defaultLoaderId = internalFrame.loaderId;
266
}
267
this.setLoader(internalFrame.loaderId);
268
269
if (this.url || internalFrame.unreachableUrl) {
270
// if this is a loaded frame, just count it as loaded. it shouldn't fail
271
this.navigationLoadersById[internalFrame.loaderId].setNavigationResult(internalFrame.url);
272
}
273
}
274
275
public onNavigated(frame: PageFrame): void {
276
this.internalFrame = frame;
277
this.updateUrl();
278
279
const loader = this.navigationLoadersById[frame.loaderId] ?? this.activeLoader;
280
281
if (frame.unreachableUrl) {
282
loader.setNavigationResult(
283
new Error(`Unreachable url for navigation "${frame.unreachableUrl}"`),
284
);
285
} else {
286
loader.setNavigationResult(frame.url);
287
}
288
289
this.emit('frame-navigated', { frame: this, loaderId: frame.loaderId });
290
}
291
292
public onNavigatedWithinDocument(url: string): void {
293
if (this.url === url) return;
294
// we're using params on about:blank, so make sure to strip for url
295
if (url.startsWith(DEFAULT_PAGE)) url = DEFAULT_PAGE;
296
this.url = url;
297
298
const isDomLoaded = this.activeLoader?.lifecycle?.DOMContentLoaded;
299
300
const loaderId = `${InPageNavigationLoaderPrefix}${(this.inPageCounter += 1)}`;
301
this.setLoader(loaderId, url);
302
if (isDomLoaded) {
303
this.activeLoader.markLoaded();
304
}
305
this.emit('frame-navigated', { frame: this, navigatedInDocument: true, loaderId });
306
}
307
308
/////// LIFECYCLE ////////////////////////////////////////////////////////////////////////////////////////////////////
309
310
public onStoppedLoading(): void {
311
if (!this.startedLoaderId) return;
312
313
const loader = this.navigationLoadersById[this.startedLoaderId];
314
loader?.onStoppedLoading();
315
}
316
317
public async waitForLoader(loaderId?: string, timeoutMs?: number): Promise<Error | null> {
318
if (!loaderId) {
319
loaderId = this.activeLoaderId;
320
if (loaderId === this.defaultLoaderId) {
321
// wait for an actual frame to load
322
const frameLoader = await this.waitOn('frame-loader-created', null, timeoutMs ?? 60e3);
323
loaderId = frameLoader.loaderId;
324
}
325
}
326
327
const hasLoaderError = await this.navigationLoadersById[loaderId]?.navigationResolver;
328
if (hasLoaderError instanceof Error) return hasLoaderError;
329
330
if (!this.getActiveContextId(false)) {
331
await this.waitForDefaultContext();
332
}
333
}
334
335
public onLifecycleEvent(name: string, pageLoaderId?: string): void {
336
const loaderId = pageLoaderId ?? this.activeLoaderId;
337
if (name === 'init' && pageLoaderId) {
338
// if the active loader never initiates before this new one, we should notify
339
if (
340
this.activeLoaderId &&
341
this.activeLoaderId !== pageLoaderId &&
342
!this.activeLoader.lifecycle.init &&
343
!this.activeLoader.isNavigationComplete
344
) {
345
this.activeLoader.setNavigationResult(new CanceledPromiseError('Navigation canceled'));
346
}
347
this.startedLoaderId = pageLoaderId;
348
}
349
350
if (!this.navigationLoadersById[loaderId]) {
351
this.setLoader(loaderId);
352
}
353
354
this.navigationLoadersById[loaderId].onLifecycleEvent(name);
355
if (loaderId !== this.activeLoaderId) {
356
let checkLoaderForInPage = false;
357
for (const [historicalLoaderId, loader] of Object.entries(this.navigationLoadersById)) {
358
if (loaderId === historicalLoaderId) {
359
checkLoaderForInPage = true;
360
}
361
362
if (checkLoaderForInPage && historicalLoaderId.startsWith(InPageNavigationLoaderPrefix)) {
363
loader.onLifecycleEvent(name);
364
this.emit('frame-lifecycle', { frame: this, name, loader });
365
}
366
}
367
}
368
369
if (loaderId !== this.defaultLoaderId) {
370
this.emit('frame-lifecycle', {
371
frame: this,
372
name,
373
loader: this.navigationLoadersById[loaderId],
374
});
375
}
376
}
377
378
/////// CONTEXT ID //////////////////////////////////////////////////////////////////////////////////////////////////
379
380
public hasContextId(executionContextId: number): boolean {
381
return (
382
this.defaultContextId === executionContextId || this.isolatedContextId === executionContextId
383
);
384
}
385
386
public removeContextId(executionContextId: number): void {
387
if (this.defaultContextId === executionContextId) this.defaultContextId = null;
388
if (this.isolatedContextId === executionContextId) this.isolatedContextId = null;
389
}
390
391
public clearContextIds(): void {
392
this.defaultContextId = null;
393
this.isolatedContextId = null;
394
}
395
396
public addContextId(executionContextId: number, isDefault: boolean): void {
397
if (isDefault) {
398
this.defaultContextId = executionContextId;
399
this.defaultContextCreated?.resolve();
400
} else {
401
this.isolatedContextId = executionContextId;
402
}
403
}
404
405
public getActiveContextId(isolatedContext: boolean): number | undefined {
406
let id: number;
407
if (isolatedContext) {
408
id = this.isolatedContextId;
409
} else {
410
id = this.defaultContextId;
411
}
412
if (id && this.activeContextIds.has(id)) return id;
413
}
414
415
public async waitForActiveContextId(isolatedContext = true): Promise<number> {
416
if (!this.isAttached) throw new Error('Execution Context is not available in detached frame');
417
418
const existing = this.getActiveContextId(isolatedContext);
419
if (existing) return existing;
420
421
if (isolatedContext) {
422
const context = await this.createIsolatedWorld();
423
// give one task to set up
424
await new Promise(setImmediate);
425
return context;
426
}
427
428
await this.waitForDefaultContext();
429
return this.getActiveContextId(isolatedContext);
430
}
431
432
public canEvaluate(isolatedFromWebPageEnvironment: boolean): boolean {
433
return this.getActiveContextId(isolatedFromWebPageEnvironment) !== undefined;
434
}
435
436
public toJSON() {
437
return {
438
id: this.id,
439
parentId: this.parentId,
440
name: this.name,
441
url: this.url,
442
navigationReason: this.navigationReason,
443
disposition: this.disposition,
444
activeLoader: this.activeLoader,
445
};
446
}
447
448
private setLoader(loaderId: string, url?: string): void {
449
if (!loaderId) return;
450
if (loaderId === this.activeLoaderId) return;
451
452
if (this.navigationLoadersById[loaderId]) return;
453
454
this.activeLoaderId = loaderId;
455
456
this.logger.info('Queuing new navigation loader', {
457
loaderId,
458
frameId: this.id,
459
});
460
this.navigationLoadersById[loaderId] = new NavigationLoader(loaderId, this.logger);
461
if (url) this.navigationLoadersById[loaderId].url = url;
462
463
this.emit('frame-loader-created', {
464
frame: this,
465
loaderId,
466
});
467
}
468
469
private async createIsolatedWorld(): Promise<number> {
470
try {
471
if (!this.isAttached) return;
472
const isolatedWorld = await this.devtoolsSession.send(
473
'Page.createIsolatedWorld',
474
{
475
frameId: this.id,
476
worldName: ISOLATED_WORLD,
477
// param is misspelled in protocol
478
grantUniveralAccess: true,
479
},
480
this,
481
);
482
const { executionContextId } = isolatedWorld;
483
if (!this.activeContextIds.has(executionContextId)) {
484
this.activeContextIds.add(executionContextId);
485
this.addContextId(executionContextId, false);
486
this.getFrameElementNodeId().catch(() => null);
487
}
488
489
return executionContextId;
490
} catch (error) {
491
if (error instanceof CanceledPromiseError) {
492
return;
493
}
494
if (error instanceof ProtocolError) {
495
// 32000 code means frame doesn't exist, see if we just missed timing
496
if (error.remoteError?.code === ContextNotFoundCode) {
497
if (!this.isAttached) return;
498
}
499
}
500
this.logger.warn('Failed to create isolated world.', {
501
frameId: this.id,
502
error,
503
});
504
}
505
}
506
507
private async waitForDefaultContext(): Promise<void> {
508
if (this.getActiveContextId(false)) return;
509
510
this.defaultContextCreated = new Resolvable<void>();
511
// don't time out this event, we'll just wait for the page to shut down
512
await this.defaultContextCreated.promise.catch(err => {
513
if (err instanceof CanceledPromiseError) return;
514
throw err;
515
});
516
}
517
518
private updateUrl(): void {
519
if (this.internalFrame.url) {
520
this.url = this.internalFrame.url + (this.internalFrame.urlFragment ?? '');
521
} else {
522
this.url = undefined;
523
}
524
}
525
}
526
527