Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/puppet-chrome/lib/FramesManager.ts
1028 views
1
import Protocol from 'devtools-protocol';
2
import EventSubscriber from '@secret-agent/commons/EventSubscriber';
3
import { TypedEventEmitter } from '@secret-agent/commons/eventUtils';
4
import { IPuppetFrameManagerEvents } from '@secret-agent/interfaces/IPuppetFrame';
5
import IRegisteredEventListener from '@secret-agent/interfaces/IRegisteredEventListener';
6
import { IBoundLog } from '@secret-agent/interfaces/ILog';
7
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
8
import injectedSourceUrl from '@secret-agent/interfaces/injectedSourceUrl';
9
import { DevtoolsSession } from './DevtoolsSession';
10
import Frame from './Frame';
11
import FrameNavigatedEvent = Protocol.Page.FrameNavigatedEvent;
12
import FrameTree = Protocol.Page.FrameTree;
13
import FrameDetachedEvent = Protocol.Page.FrameDetachedEvent;
14
import FrameAttachedEvent = Protocol.Page.FrameAttachedEvent;
15
import ExecutionContextDestroyedEvent = Protocol.Runtime.ExecutionContextDestroyedEvent;
16
import ExecutionContextCreatedEvent = Protocol.Runtime.ExecutionContextCreatedEvent;
17
import NavigatedWithinDocumentEvent = Protocol.Page.NavigatedWithinDocumentEvent;
18
import FrameStoppedLoadingEvent = Protocol.Page.FrameStoppedLoadingEvent;
19
import LifecycleEventEvent = Protocol.Page.LifecycleEventEvent;
20
import FrameRequestedNavigationEvent = Protocol.Page.FrameRequestedNavigationEvent;
21
import Page = Protocol.Page;
22
23
export const DEFAULT_PAGE = 'about:blank';
24
export const ISOLATED_WORLD = '__sa_world__';
25
26
export default class FramesManager extends TypedEventEmitter<IPuppetFrameManagerEvents> {
27
public framesById = new Map<string, Frame>();
28
29
public get mainFrameId() {
30
return Array.from(this.attachedFrameIds).find(id => !this.framesById.get(id).parentId);
31
}
32
33
public get main() {
34
return this.framesById.get(this.mainFrameId);
35
}
36
37
public get activeFrames() {
38
return Array.from(this.attachedFrameIds).map(x => this.framesById.get(x));
39
}
40
41
protected readonly logger: IBoundLog;
42
43
private attachedFrameIds = new Set<string>();
44
private activeContextIds = new Set<number>();
45
private readonly eventsSubscriber = new EventSubscriber();
46
private readonly devtoolsSession: DevtoolsSession;
47
48
private isReady: Promise<void>;
49
50
constructor(devtoolsSession: DevtoolsSession, logger: IBoundLog) {
51
super();
52
this.devtoolsSession = devtoolsSession;
53
this.logger = logger.createChild(module);
54
const events = this.eventsSubscriber;
55
56
events.on(devtoolsSession, 'Page.frameNavigated', this.onFrameNavigated.bind(this));
57
events.on(
58
devtoolsSession,
59
'Page.navigatedWithinDocument',
60
this.onFrameNavigatedWithinDocument.bind(this),
61
);
62
events.on(
63
devtoolsSession,
64
'Page.frameRequestedNavigation',
65
this.onFrameRequestedNavigation.bind(this),
66
);
67
events.on(devtoolsSession, 'Page.frameDetached', this.onFrameDetached.bind(this));
68
events.on(devtoolsSession, 'Page.frameAttached', this.onFrameAttached.bind(this));
69
events.on(devtoolsSession, 'Page.frameStoppedLoading', this.onFrameStoppedLoading.bind(this));
70
events.on(devtoolsSession, 'Page.lifecycleEvent', this.onLifecycleEvent.bind(this));
71
events.on(
72
devtoolsSession,
73
'Runtime.executionContextsCleared',
74
this.onExecutionContextsCleared.bind(this),
75
);
76
events.on(
77
devtoolsSession,
78
'Runtime.executionContextDestroyed',
79
this.onExecutionContextDestroyed.bind(this),
80
);
81
events.on(
82
devtoolsSession,
83
'Runtime.executionContextCreated',
84
this.onExecutionContextCreated.bind(this),
85
);
86
}
87
88
public initialize() {
89
this.isReady = new Promise<void>(async (resolve, reject) => {
90
try {
91
const [framesResponse, , readyStateResult] = await Promise.all([
92
this.devtoolsSession.send('Page.getFrameTree'),
93
this.devtoolsSession.send('Page.enable'),
94
this.devtoolsSession.send('Runtime.evaluate', {
95
expression: 'document.readyState',
96
}),
97
this.devtoolsSession.send('Page.setLifecycleEventsEnabled', { enabled: true }),
98
this.devtoolsSession.send('Runtime.enable'),
99
this.devtoolsSession.send('Page.addScriptToEvaluateOnNewDocument', {
100
source: `//# sourceURL=${injectedSourceUrl}`,
101
worldName: ISOLATED_WORLD,
102
}),
103
]);
104
this.recurseFrameTree(framesResponse.frameTree);
105
resolve();
106
if (this.main.securityOrigin && !this.main.activeLoader?.lifecycle?.load) {
107
const readyState = readyStateResult.result?.value;
108
const loaderId = this.main.activeLoaderId;
109
let loadName: string;
110
if (readyState === 'interactive') loadName = 'DOMContentLoaded';
111
else if (readyState === 'complete') loadName = 'load';
112
if (loadName) setImmediate(() => this.main.onLifecycleEvent(loadName, loaderId));
113
}
114
} catch (error) {
115
if (error instanceof CanceledPromiseError) {
116
resolve();
117
return;
118
}
119
reject(error);
120
}
121
});
122
return this.isReady;
123
}
124
125
public close(error?: Error) {
126
this.eventsSubscriber.close();
127
this.cancelPendingEvents('FramesManager closed');
128
for (const frame of this.framesById.values()) {
129
frame.close(error);
130
}
131
}
132
133
public async addPageCallback(
134
name: string,
135
onCallback: (payload: any, frameId: string) => any,
136
): Promise<IRegisteredEventListener> {
137
// add binding to every new context automatically
138
await this.devtoolsSession.send('Runtime.addBinding', {
139
name,
140
});
141
return this.eventsSubscriber.on(
142
this.devtoolsSession,
143
'Runtime.bindingCalled',
144
async (event: Protocol.Runtime.BindingCalledEvent) => {
145
if (event.name === name) {
146
await this.isReady;
147
const frameId = this.getFrameIdForExecutionContext(event.executionContextId);
148
onCallback(event.payload, frameId);
149
}
150
},
151
);
152
}
153
154
public async addNewDocumentScript(script: string, installInIsolatedScope = true) {
155
await this.devtoolsSession.send('Page.addScriptToEvaluateOnNewDocument', {
156
source: script,
157
worldName: installInIsolatedScope ? ISOLATED_WORLD : undefined,
158
});
159
160
// sometimes we get a new anchor link that already has an initiated frame. If that's the case, newDocumentScripts won't trigger.
161
// NOTE: we DON'T want this to trigger for internal pages (':', 'about:blank')
162
if (this.main.url?.startsWith('http')) {
163
await this.main.evaluate(script, installInIsolatedScope, { retriesWaitingForLoad: 1 });
164
}
165
}
166
167
/////// EXECUTION CONTEXT ////////////////////////////////////////////////////
168
169
public getSecurityOrigins() {
170
const origins: { origin: string; frameId: string }[] = [];
171
for (const frame of this.framesById.values()) {
172
if (this.attachedFrameIds.has(frame.id)) {
173
const origin = frame.securityOrigin;
174
if (origin && !origins.some(x => x.origin === origin)) {
175
origins.push({ origin, frameId: frame.id });
176
}
177
}
178
}
179
return origins;
180
}
181
182
public async waitForFrame(
183
frameDetails: { frameId: string; loaderId?: string },
184
url: string,
185
isInitiatingNavigation = false,
186
) {
187
await this.isReady;
188
const { frameId, loaderId } = frameDetails;
189
const frame = this.framesById.get(frameId);
190
if (isInitiatingNavigation) {
191
frame.initiateNavigation(url, loaderId);
192
}
193
const loaderError = await frame.waitForLoader(loaderId);
194
if (loaderError) throw loaderError;
195
}
196
197
public getFrameIdForExecutionContext(executionContextId: number) {
198
for (const frame of this.framesById.values()) {
199
if (frame.hasContextId(executionContextId)) return frame.id;
200
}
201
}
202
203
private async onExecutionContextDestroyed(event: ExecutionContextDestroyedEvent) {
204
await this.isReady;
205
this.activeContextIds.delete(event.executionContextId);
206
for (const frame of this.framesById.values()) {
207
frame.removeContextId(event.executionContextId);
208
}
209
}
210
211
private async onExecutionContextsCleared() {
212
await this.isReady;
213
this.activeContextIds.clear();
214
for (const frame of this.framesById.values()) {
215
frame.clearContextIds();
216
}
217
}
218
219
private async onExecutionContextCreated(event: ExecutionContextCreatedEvent) {
220
await this.isReady;
221
const { context } = event;
222
const frameId = context.auxData.frameId as string;
223
const type = context.auxData.type as string;
224
225
const defaultScope =
226
type === 'default' && context.auxData.isDefault === true && context.name === '';
227
const isolatedScope = type === 'isolated' && context.name === ISOLATED_WORLD;
228
if (!isolatedScope && !defaultScope) return;
229
230
this.activeContextIds.add(context.id);
231
const frame = this.framesById.get(frameId);
232
if (!frame) {
233
this.logger.warn('No frame for active context!', {
234
frameId,
235
executionContextId: context.id,
236
});
237
}
238
frame?.addContextId(context.id, defaultScope);
239
}
240
241
/////// FRAMES ///////////////////////////////////////////////////////////////
242
243
private async onFrameNavigated(navigatedEvent: FrameNavigatedEvent) {
244
await this.isReady;
245
const frame = this.recordFrame(navigatedEvent.frame);
246
frame.onNavigated(navigatedEvent.frame);
247
}
248
249
private async onFrameStoppedLoading(event: FrameStoppedLoadingEvent) {
250
await this.isReady;
251
const { frameId } = event;
252
253
this.framesById.get(frameId).onStoppedLoading();
254
}
255
256
private async onFrameRequestedNavigation(navigatedEvent: FrameRequestedNavigationEvent) {
257
await this.isReady;
258
const { frameId, url, reason, disposition } = navigatedEvent;
259
this.framesById.get(frameId).requestedNavigation(url, reason, disposition);
260
}
261
262
private async onFrameNavigatedWithinDocument(navigatedEvent: NavigatedWithinDocumentEvent) {
263
await this.isReady;
264
const { frameId, url } = navigatedEvent;
265
this.framesById.get(frameId).onNavigatedWithinDocument(url);
266
}
267
268
private async onFrameDetached(frameDetachedEvent: FrameDetachedEvent) {
269
await this.isReady;
270
const { frameId } = frameDetachedEvent;
271
this.attachedFrameIds.delete(frameId);
272
}
273
274
private async onFrameAttached(frameAttachedEvent: FrameAttachedEvent) {
275
await this.isReady;
276
const { frameId, parentFrameId } = frameAttachedEvent;
277
278
this.recordFrame({ id: frameId, parentId: parentFrameId } as any);
279
this.attachedFrameIds.add(frameId);
280
}
281
282
private async onLifecycleEvent(event: LifecycleEventEvent) {
283
await this.isReady;
284
const { frameId, name, loaderId } = event;
285
const frame = this.recordFrame({ id: frameId, loaderId } as any);
286
return frame.onLifecycleEvent(name, loaderId);
287
}
288
289
private recurseFrameTree(frameTree: FrameTree) {
290
const { frame, childFrames } = frameTree;
291
this.recordFrame(frame, true);
292
293
this.attachedFrameIds.add(frame.id);
294
295
if (!childFrames) return;
296
for (const childFrame of childFrames) {
297
this.recurseFrameTree(childFrame);
298
}
299
}
300
301
private recordFrame(newFrame: Page.Frame, isFrameTreeRecurse = false) {
302
const { id, parentId } = newFrame;
303
if (this.framesById.has(id)) {
304
const frame = this.framesById.get(id);
305
if (isFrameTreeRecurse) frame.onAttached(newFrame);
306
return frame;
307
}
308
309
const parentFrame = parentId ? this.framesById.get(parentId) : null;
310
const frame = new Frame(
311
newFrame,
312
this.activeContextIds,
313
this.devtoolsSession,
314
this.logger,
315
() => this.attachedFrameIds.has(id),
316
parentFrame,
317
);
318
this.framesById.set(id, frame);
319
320
this.emit('frame-created', { frame, loaderId: newFrame.loaderId });
321
322
return frame;
323
}
324
}
325
326