Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/core/lib/FrameNavigations.ts
1029 views
1
import INavigation, {
2
LoadStatus,
3
NavigationReason,
4
NavigationState,
5
} from '@secret-agent/interfaces/INavigation';
6
import { LocationStatus } from '@secret-agent/interfaces/Location';
7
import { createPromise } from '@secret-agent/commons/utils';
8
import { TypedEventEmitter } from '@secret-agent/commons/eventUtils';
9
import { IBoundLog } from '@secret-agent/interfaces/ILog';
10
import Log from '@secret-agent/commons/Logger';
11
import SessionState from './SessionState';
12
13
export interface IFrameNavigationEvents {
14
'navigation-requested': INavigation;
15
'status-change': {
16
id: number;
17
url: string;
18
stateChanges: { [state: string]: Date };
19
newStatus: NavigationState;
20
};
21
}
22
23
const { log } = Log(module);
24
25
export default class FrameNavigations extends TypedEventEmitter<IFrameNavigationEvents> {
26
public get top(): INavigation {
27
return this.history.length > 0 ? this.history[this.history.length - 1] : null;
28
}
29
30
public get currentUrl(): string {
31
const top = this.top;
32
if (!top) return '';
33
return top.finalUrl ?? top.requestedUrl;
34
}
35
36
// last navigation not loaded in-page
37
public lastHttpNavigation: INavigation;
38
39
public history: INavigation[] = [];
40
41
public logger: IBoundLog;
42
43
private historyByLoaderId: { [loaderId: string]: INavigation } = {};
44
45
private nextNavigationReason: { url: string; reason: NavigationReason };
46
47
constructor(readonly frameId: number, readonly sessionState: SessionState) {
48
super();
49
this.setEventsToLog(['navigation-requested', 'status-change']);
50
this.logger = log.createChild(module, {
51
sessionId: sessionState.sessionId,
52
frameId,
53
});
54
}
55
56
public didGotoUrl(url: string): boolean {
57
return this.history.some(x => x.requestedUrl === url && x.navigationReason === 'goto');
58
}
59
60
public onNavigationRequested(
61
reason: NavigationReason,
62
url: string,
63
commandId: number,
64
loaderId: string,
65
browserRequestId?: string,
66
): INavigation {
67
const nextTop = <INavigation>{
68
requestedUrl: url,
69
finalUrl: null,
70
frameId: this.frameId,
71
loaderId,
72
startCommandId: commandId,
73
navigationReason: reason,
74
initiatedTime: new Date(),
75
stateChanges: new Map(),
76
resourceId: createPromise(),
77
browserRequestId,
78
};
79
if (loaderId) this.historyByLoaderId[loaderId] = nextTop;
80
81
this.checkStoredNavigationReason(nextTop, url);
82
83
const currentTop = this.top;
84
let shouldPublishLocationChange = false;
85
// if in-page, set the state to match current top
86
if (reason === 'inPage') {
87
if (currentTop) {
88
if (url === currentTop.finalUrl) return;
89
90
for (const state of currentTop.stateChanges.keys()) {
91
if (isLoadState(state)) {
92
nextTop.stateChanges.set(state, new Date());
93
}
94
}
95
nextTop.resourceId.resolve(currentTop.resourceId.promise);
96
} else {
97
nextTop.stateChanges.set(LoadStatus.Load, nextTop.initiatedTime);
98
nextTop.stateChanges.set(LoadStatus.ContentPaint, nextTop.initiatedTime);
99
nextTop.resourceId.resolve(-1);
100
}
101
shouldPublishLocationChange = true;
102
nextTop.finalUrl = url;
103
} else {
104
this.lastHttpNavigation = nextTop;
105
}
106
this.history.push(nextTop);
107
108
this.emit('navigation-requested', nextTop);
109
this.captureNavigationUpdate(nextTop);
110
if (shouldPublishLocationChange) {
111
this.emit('status-change', {
112
id: nextTop.id,
113
newStatus: LoadStatus.ContentPaint,
114
url,
115
// @ts-ignore
116
stateChanges: Object.fromEntries(nextTop.stateChanges),
117
});
118
}
119
return nextTop;
120
}
121
122
public onHttpRequested(
123
url: string,
124
lastCommandId: number,
125
redirectedFromUrl: string,
126
browserRequestId: string,
127
loaderId: string,
128
): void {
129
if (url === 'about:blank') return;
130
// if this is a redirect, capture in top
131
if (!this.top) return;
132
133
let reason: NavigationReason;
134
if (redirectedFromUrl) {
135
const redirectedNavigation = this.recordRedirect(redirectedFromUrl, url, loaderId);
136
reason = redirectedNavigation?.navigationReason;
137
}
138
139
const top = this.top;
140
141
const isHistoryNavigation =
142
top.navigationReason === 'goBack' || top.navigationReason === 'goForward';
143
if (!top.requestedUrl && isHistoryNavigation) {
144
top.requestedUrl = url;
145
} else if (
146
!top.requestedUrl &&
147
top.navigationReason === 'newFrame' &&
148
top.loaderId === loaderId
149
) {
150
top.requestedUrl = url;
151
this.checkStoredNavigationReason(top, url);
152
}
153
// if we already have this status at top level, this is a new nav
154
else if (
155
top.stateChanges.has(LocationStatus.HttpRequested) === true &&
156
// add new entries for redirects
157
(!this.historyByLoaderId[loaderId] || redirectedFromUrl)
158
) {
159
this.onNavigationRequested(reason, url, lastCommandId, loaderId, browserRequestId);
160
}
161
162
this.changeNavigationState(LocationStatus.HttpRequested, loaderId);
163
}
164
165
public onHttpResponded(browserRequestId: string, url: string, loaderId: string): void {
166
if (url === 'about:blank') return;
167
168
const navigation = this.findMatchingNavigation(loaderId);
169
navigation.finalUrl = url;
170
171
this.recordStatusChange(navigation, LocationStatus.HttpResponded);
172
}
173
174
public doesMatchPending(
175
browserRequestId: string,
176
requestedUrl: string,
177
finalUrl: string,
178
): boolean {
179
const top = this.lastHttpNavigation;
180
if (!top || top.resourceId.isResolved) return false;
181
182
// hash won't be in the http request
183
const frameRequestedUrl = top.requestedUrl?.split('#')?.shift();
184
185
if (
186
(top.finalUrl && finalUrl === top.finalUrl) ||
187
requestedUrl === top.requestedUrl ||
188
requestedUrl === frameRequestedUrl ||
189
browserRequestId === top.browserRequestId
190
) {
191
return true;
192
}
193
return false;
194
}
195
196
public onResourceLoaded(resourceId: number, statusCode: number, error?: Error): void {
197
this.logger.info('NavigationResource resolved', {
198
resourceId,
199
statusCode,
200
error,
201
currentUrl: this.currentUrl,
202
});
203
const top = this.lastHttpNavigation;
204
if (!top || top.resourceId.isResolved) return;
205
206
// since we don't know if there are listeners yet, we need to just set the error on the return value
207
// otherwise, get unhandledrejections
208
if (error) top.navigationError = error;
209
210
top.resourceId.resolve(resourceId);
211
}
212
213
public onLoadStateChanged(
214
incomingStatus: LoadStatus.DomContentLoaded | LoadStatus.Load | LoadStatus.ContentPaint,
215
url: string,
216
loaderId: string,
217
statusChangeDate?: Date,
218
): void {
219
if (url === 'about:blank') return;
220
// if this is a painting stable, it probably won't come from a loader event for the page
221
if (!loaderId) {
222
for (let i = this.history.length - 1; i >= 0; i -= 1) {
223
const nav = this.history[i];
224
const isUrlMatch = nav.finalUrl === url || nav.requestedUrl === url;
225
if (isUrlMatch && nav.stateChanges.has(LoadStatus.HttpResponded)) {
226
loaderId = nav.loaderId;
227
break;
228
}
229
}
230
}
231
this.changeNavigationState(incomingStatus, loaderId, statusChangeDate);
232
}
233
234
public updateNavigationReason(url: string, reason: NavigationReason): void {
235
const top = this.top;
236
if (
237
top &&
238
top.requestedUrl === url &&
239
(top.navigationReason === null || top.navigationReason === 'newFrame')
240
) {
241
top.navigationReason = reason;
242
this.captureNavigationUpdate(top);
243
} else {
244
this.nextNavigationReason = { url, reason };
245
}
246
}
247
248
public assignLoaderId(navigation: INavigation, loaderId: string, url?: string): void {
249
if (!loaderId) return;
250
251
this.historyByLoaderId[loaderId] ??= navigation;
252
navigation.loaderId = loaderId;
253
if (
254
url &&
255
(navigation.navigationReason === 'goBack' || navigation.navigationReason === 'goForward')
256
) {
257
navigation.requestedUrl = url;
258
}
259
this.captureNavigationUpdate(navigation);
260
}
261
262
public getLastLoadedNavigation(): INavigation {
263
let navigation: INavigation;
264
let hasInPageNav = false;
265
for (let i = this.history.length - 1; i >= 0; i -= 1) {
266
navigation = this.history[i];
267
if (navigation.navigationReason === 'inPage') {
268
hasInPageNav = true;
269
continue;
270
}
271
if (!navigation.finalUrl || !navigation.stateChanges.has(LoadStatus.HttpResponded)) continue;
272
273
// if we have an in-page nav, return the first non "inPage" url. Otherwise, use if DomContentLoaded was triggered
274
if (hasInPageNav || navigation.stateChanges.has(LoadStatus.DomContentLoaded)) {
275
return navigation;
276
}
277
}
278
return this.top;
279
}
280
281
private checkStoredNavigationReason(navigation: INavigation, url: string): void {
282
if (
283
this.nextNavigationReason &&
284
this.nextNavigationReason.url === url &&
285
(!navigation.navigationReason || navigation.navigationReason === 'newFrame')
286
) {
287
navigation.navigationReason = this.nextNavigationReason.reason;
288
this.nextNavigationReason = null;
289
}
290
}
291
292
private findMatchingNavigation(loaderId: string): INavigation {
293
return this.historyByLoaderId[loaderId] ?? this.top;
294
}
295
296
private recordRedirect(requestedUrl: string, finalUrl: string, loaderId: string): INavigation {
297
const top = this.top;
298
if (top.requestedUrl === requestedUrl && !top.finalUrl && !top.loaderId) {
299
top.loaderId = loaderId;
300
top.finalUrl = finalUrl;
301
this.recordStatusChange(top, LocationStatus.HttpRedirected);
302
return top;
303
}
304
305
// find the right loader id
306
// NOTE: loop through history since loaderId is reused across requests in a redirect
307
for (let i = this.history.length - 1; i >= 0; i -= 1) {
308
const navigation = this.history[i];
309
if (navigation && navigation.loaderId === loaderId) {
310
if (
311
!navigation.stateChanges.has(LocationStatus.HttpRedirected) &&
312
navigation.requestedUrl === requestedUrl
313
) {
314
navigation.finalUrl = finalUrl;
315
this.recordStatusChange(navigation, LocationStatus.HttpRedirected);
316
return navigation;
317
}
318
}
319
}
320
}
321
322
private changeNavigationState(
323
newStatus: NavigationState,
324
loaderId?: string,
325
statusChangeDate?: Date,
326
): void {
327
this.logger.info('FrameNavigations.changeNavigationState', {
328
newStatus,
329
loaderId,
330
statusChangeDate,
331
});
332
const navigation = this.findMatchingNavigation(loaderId);
333
if (!navigation) return;
334
if (!navigation.loaderId && loaderId) {
335
navigation.loaderId = loaderId;
336
this.historyByLoaderId[loaderId] ??= navigation;
337
}
338
if (navigation.stateChanges.has(newStatus)) {
339
if (statusChangeDate && statusChangeDate < navigation.stateChanges.get(newStatus)) {
340
navigation.stateChanges.set(newStatus, statusChangeDate);
341
}
342
return;
343
}
344
this.recordStatusChange(navigation, newStatus, statusChangeDate);
345
}
346
347
private recordStatusChange(
348
navigation: INavigation,
349
newStatus: NavigationState,
350
statusChangeDate?: Date,
351
): void {
352
navigation.stateChanges.set(newStatus, statusChangeDate ?? new Date());
353
354
this.emit('status-change', {
355
id: navigation.id,
356
url: navigation.finalUrl ?? navigation.requestedUrl,
357
// @ts-ignore - Typescript refuses to recognize this function
358
stateChanges: Object.fromEntries(navigation.stateChanges),
359
newStatus,
360
});
361
this.captureNavigationUpdate(navigation);
362
}
363
364
private captureNavigationUpdate(navigation: INavigation): void {
365
this.sessionState.recordNavigation(navigation);
366
}
367
}
368
369
function isLoadState(status: NavigationState): boolean {
370
return (
371
status === LoadStatus.ContentPaint ||
372
status === LoadStatus.Load ||
373
status === LoadStatus.DomContentLoaded
374
);
375
}
376
377