Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/core/lib/FrameNavigationsObserver.ts
1029 views
1
import { assert, createPromise } from '@secret-agent/commons/utils';
2
import type {
3
ILocationStatus,
4
ILocationTrigger,
5
IPipelineStatus,
6
} from '@secret-agent/interfaces/Location';
7
import { LocationStatus, LocationTrigger, PipelineStatus } from '@secret-agent/interfaces/Location';
8
import INavigation, { LoadStatus, NavigationReason } from '@secret-agent/interfaces/INavigation';
9
import type ICommandMeta from '@secret-agent/interfaces/ICommandMeta';
10
import type IWaitForOptions from '@secret-agent/interfaces/IWaitForOptions';
11
import type IResolvablePromise from '@secret-agent/interfaces/IResolvablePromise';
12
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
13
import * as moment from 'moment';
14
import type { IBoundLog } from '@secret-agent/interfaces/ILog';
15
import type FrameNavigations from './FrameNavigations';
16
17
export default class FrameNavigationsObserver {
18
private readonly navigations: FrameNavigations;
19
20
// this is the default "starting" point for a wait-for location change if a previous command id is not specified
21
private defaultWaitForLocationCommandId = 0;
22
23
private waitingForLoadTimeout: NodeJS.Timeout;
24
private resourceIdResolvable: IResolvablePromise<number>;
25
private statusTriggerResolvable: IResolvablePromise<void>;
26
private statusTrigger: ILocationStatus;
27
private statusTriggerStartCommandId: number;
28
private logger: IBoundLog;
29
30
constructor(navigations: FrameNavigations) {
31
this.navigations = navigations;
32
this.logger = navigations.logger.createChild(module);
33
navigations.on('status-change', this.onLoadStatusChange.bind(this));
34
}
35
36
// this function will find the "starting command" to look for waitForLocation(change/reload)
37
public willRunCommand(newCommand: ICommandMeta, previousCommands: ICommandMeta[]) {
38
let last: ICommandMeta;
39
for (const command of previousCommands) {
40
// if this is a goto, set this to the "waitForLocation(change/reload)" command marker
41
if (command.name === 'goto') this.defaultWaitForLocationCommandId = command.id;
42
// find the last "waitFor" command that is not followed by another waitFor
43
if (last?.name.startsWith('waitFor') && !command.name.startsWith('waitFor')) {
44
this.defaultWaitForLocationCommandId = command.id;
45
}
46
last = command;
47
}
48
// handle cases like waitForLocation two times in a row
49
if (
50
newCommand.name === 'waitForLocation' &&
51
last &&
52
last.name.startsWith('waitFor') &&
53
last.name !== 'waitForMillis'
54
) {
55
this.defaultWaitForLocationCommandId = newCommand.id;
56
}
57
}
58
59
public waitForLocation(status: ILocationTrigger, options: IWaitForOptions = {}): Promise<void> {
60
assert(LocationTrigger[status], `Invalid location status: ${status}`);
61
62
// determine if this location trigger has already been satisfied
63
const sinceCommandId = Number.isInteger(options.sinceCommandId)
64
? options.sinceCommandId
65
: this.defaultWaitForLocationCommandId;
66
if (this.hasLocationTrigger(status, sinceCommandId)) {
67
return Promise.resolve();
68
}
69
// otherwise set pending
70
return this.createStatusTriggeredPromise(status, options.timeoutMs, sinceCommandId);
71
}
72
73
public waitForLoad(status: IPipelineStatus, options: IWaitForOptions = {}): Promise<void> {
74
assert(PipelineStatus[status], `Invalid load status: ${status}`);
75
76
if (options.sinceCommandId) {
77
throw new Error('Not implemented');
78
}
79
80
const top = this.navigations.top;
81
if (top) {
82
if (top.stateChanges.has(status as LoadStatus)) {
83
return;
84
}
85
if (status === LocationStatus.DomContentLoaded && top.stateChanges.has(LoadStatus.Load)) {
86
return;
87
}
88
if (status === LocationStatus.PaintingStable && this.getPaintStableStatus().isStable) {
89
return;
90
}
91
}
92
const promise = this.createStatusTriggeredPromise(status, options.timeoutMs);
93
94
if (top) this.onLoadStatusChange();
95
return promise;
96
}
97
98
public waitForReady(): Promise<void> {
99
return this.waitForLoad(LocationStatus.HttpResponded);
100
}
101
102
public async waitForNavigationResourceId(): Promise<number> {
103
const top = this.navigations.top;
104
105
this.resourceIdResolvable = top?.resourceId;
106
const resourceId = await this.resourceIdResolvable?.promise;
107
if (top?.navigationError) {
108
throw top.navigationError;
109
}
110
return resourceId;
111
}
112
113
public cancelWaiting(cancelMessage: string): void {
114
clearTimeout(this.waitingForLoadTimeout);
115
for (const promise of [this.resourceIdResolvable, this.statusTriggerResolvable]) {
116
if (!promise || promise.isResolved) continue;
117
118
const canceled = new CanceledPromiseError(cancelMessage);
119
canceled.stack += `\n${'------LOCATION'.padEnd(50, '-')}\n${promise.stack}`;
120
promise.reject(canceled);
121
}
122
}
123
124
public getPaintStableStatus(): { isStable: boolean; timeUntilReadyMs?: number } {
125
const top = this.navigations.top;
126
if (!top) return { isStable: false };
127
128
// need to wait for both load + painting stable, or wait 3 seconds after either one
129
const loadDate = top.stateChanges.get(LoadStatus.Load);
130
const contentPaintedDate = top.stateChanges.get(LoadStatus.ContentPaint);
131
132
if (contentPaintedDate) return { isStable: true };
133
if (!loadDate && !contentPaintedDate) return { isStable: false };
134
135
// NOTE: LargestContentfulPaint, which currently drives PaintingStable will NOT trigger if the page
136
// doesn't have any "contentful" items that are eligible (image, headers, divs, paragraphs that fill the page)
137
138
// have contentPaintedDate date, but no load
139
const timeUntilReadyMs = moment().diff(contentPaintedDate ?? loadDate, 'milliseconds');
140
return {
141
isStable: timeUntilReadyMs >= 3e3,
142
timeUntilReadyMs: Math.min(3e3, 3e3 - timeUntilReadyMs),
143
};
144
}
145
146
private onLoadStatusChange(): void {
147
if (
148
this.statusTrigger === LocationTrigger.change ||
149
this.statusTrigger === LocationTrigger.reload
150
) {
151
if (this.hasLocationTrigger(this.statusTrigger, this.statusTriggerStartCommandId)) {
152
this.resolvePendingStatus(this.statusTrigger);
153
}
154
return;
155
}
156
157
const loadTrigger = PipelineStatus[this.statusTrigger];
158
if (!this.statusTriggerResolvable || this.statusTriggerResolvable.isResolved || !loadTrigger)
159
return;
160
161
if (this.statusTrigger === LocationStatus.PaintingStable) {
162
this.waitForPageLoaded();
163
return;
164
}
165
166
// otherwise just look for state changes > the trigger
167
for (const state of this.navigations.top.stateChanges.keys()) {
168
// don't resolve states for redirected
169
if (state === LocationStatus.HttpRedirected) continue;
170
let pipelineStatus = PipelineStatus[state as IPipelineStatus];
171
if (state === LoadStatus.Load) {
172
pipelineStatus = PipelineStatus.AllContentLoaded;
173
}
174
if (pipelineStatus >= loadTrigger) {
175
this.resolvePendingStatus(state);
176
return;
177
}
178
}
179
}
180
181
private waitForPageLoaded(): void {
182
clearTimeout(this.waitingForLoadTimeout);
183
184
const { isStable, timeUntilReadyMs } = this.getPaintStableStatus();
185
186
if (isStable) this.resolvePendingStatus('PaintingStable + Load');
187
188
if (!isStable && timeUntilReadyMs) {
189
const loadDate = this.navigations.top.stateChanges.get(LoadStatus.Load);
190
const contentPaintDate = this.navigations.top.stateChanges.get(LoadStatus.ContentPaint);
191
this.waitingForLoadTimeout = setTimeout(
192
() =>
193
this.resolvePendingStatus(
194
`TimeElapsed. Loaded="${loadDate}", ContentPaint="${contentPaintDate}"`,
195
),
196
timeUntilReadyMs,
197
).unref();
198
}
199
}
200
201
private resolvePendingStatus(resolvedWithStatus: string): void {
202
if (this.statusTriggerResolvable && !this.statusTriggerResolvable?.isResolved) {
203
this.logger.info(`Resolving pending "${this.statusTrigger}" with trigger`, {
204
resolvedWithStatus,
205
waitingForStatus: this.statusTrigger,
206
url: this.navigations.currentUrl,
207
});
208
clearTimeout(this.waitingForLoadTimeout);
209
this.statusTriggerResolvable.resolve();
210
this.statusTriggerResolvable = null;
211
this.statusTrigger = null;
212
this.statusTriggerStartCommandId = null;
213
}
214
}
215
216
private hasLocationTrigger(trigger: ILocationTrigger, sinceCommandId: number) {
217
let previousLoadedNavigation: INavigation;
218
for (const history of this.navigations.history) {
219
const isMatch = history.startCommandId >= sinceCommandId;
220
if (isMatch) {
221
let isLocationChange = false;
222
if (trigger === LocationTrigger.reload) {
223
isLocationChange = FrameNavigationsObserver.isNavigationReload(history.navigationReason);
224
if (
225
!isLocationChange &&
226
!history.stateChanges.has(LoadStatus.HttpRedirected) &&
227
previousLoadedNavigation &&
228
previousLoadedNavigation.finalUrl === history.finalUrl
229
) {
230
isLocationChange = previousLoadedNavigation.loaderId !== history.loaderId;
231
}
232
}
233
234
// if there was a previously loaded url, use this change
235
if (
236
trigger === LocationTrigger.change &&
237
previousLoadedNavigation &&
238
previousLoadedNavigation.finalUrl !== history.finalUrl
239
) {
240
// Don't accept adding a slash as a page change
241
const isInPageUrlAdjust =
242
history.navigationReason === 'inPage' &&
243
history.finalUrl.replace(previousLoadedNavigation.finalUrl, '').length <= 1;
244
245
if (!isInPageUrlAdjust) isLocationChange = true;
246
}
247
248
if (isLocationChange) {
249
this.logger.info(`Resolving waitForLocation(${trigger}) with navigation history`, {
250
historyEntry: history,
251
status: trigger,
252
sinceCommandId,
253
});
254
return true;
255
}
256
}
257
258
if (
259
(history.stateChanges.has(LoadStatus.HttpResponded) ||
260
history.stateChanges.has(LoadStatus.DomContentLoaded)) &&
261
!history.stateChanges.has(LoadStatus.HttpRedirected)
262
) {
263
previousLoadedNavigation = history;
264
}
265
}
266
return false;
267
}
268
269
private createStatusTriggeredPromise(
270
status: ILocationStatus,
271
timeoutMs: number,
272
sinceCommandId?: number,
273
): Promise<void> {
274
if (this.statusTriggerResolvable) this.cancelWaiting('New location trigger created');
275
276
this.statusTrigger = status;
277
this.statusTriggerStartCommandId = sinceCommandId;
278
this.statusTriggerResolvable = createPromise<void>(timeoutMs ?? 60e3);
279
return this.statusTriggerResolvable.promise;
280
}
281
282
private static isNavigationReload(reason: NavigationReason): boolean {
283
return reason === 'httpHeaderRefresh' || reason === 'metaTagRefresh' || reason === 'reload';
284
}
285
}
286
287