Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/puppet-chrome/lib/NetworkManager.ts
1028 views
1
import { Protocol } from 'devtools-protocol';
2
import { getResourceTypeForChromeValue } from '@secret-agent/interfaces/ResourceType';
3
import { TypedEventEmitter } from '@secret-agent/commons/eventUtils';
4
import {
5
IPuppetNetworkEvents,
6
IPuppetResourceRequest,
7
} from '@secret-agent/interfaces/IPuppetNetworkEvents';
8
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
9
import EventSubscriber from '@secret-agent/commons/EventSubscriber';
10
import { IBoundLog } from '@secret-agent/interfaces/ILog';
11
import { URL } from 'url';
12
import IProxyConnectionOptions from '@secret-agent/interfaces/IProxyConnectionOptions';
13
import { DevtoolsSession } from './DevtoolsSession';
14
import AuthChallengeResponse = Protocol.Fetch.AuthChallengeResponseResponse;
15
import Fetch = Protocol.Fetch;
16
import RequestWillBeSentEvent = Protocol.Network.RequestWillBeSentEvent;
17
import WebSocketFrameSentEvent = Protocol.Network.WebSocketFrameSentEvent;
18
import WebSocketFrameReceivedEvent = Protocol.Network.WebSocketFrameReceivedEvent;
19
import WebSocketWillSendHandshakeRequestEvent = Protocol.Network.WebSocketWillSendHandshakeRequestEvent;
20
import ResponseReceivedEvent = Protocol.Network.ResponseReceivedEvent;
21
import RequestPausedEvent = Protocol.Fetch.RequestPausedEvent;
22
import LoadingFinishedEvent = Protocol.Network.LoadingFinishedEvent;
23
import LoadingFailedEvent = Protocol.Network.LoadingFailedEvent;
24
import RequestServedFromCacheEvent = Protocol.Network.RequestServedFromCacheEvent;
25
import RequestWillBeSentExtraInfoEvent = Protocol.Network.RequestWillBeSentExtraInfoEvent;
26
27
interface IResourcePublishing {
28
hasRequestWillBeSentEvent: boolean;
29
emitTimeout?: NodeJS.Timeout;
30
isPublished?: boolean;
31
isDetailsEmitted?: boolean;
32
}
33
34
const mbBytes = 1028 * 1028;
35
36
export class NetworkManager extends TypedEventEmitter<IPuppetNetworkEvents> {
37
protected readonly logger: IBoundLog;
38
private readonly devtools: DevtoolsSession;
39
private readonly attemptedAuthentications = new Set<string>();
40
private readonly redirectsById = new Map<string, IPuppetResourceRequest[]>();
41
private readonly requestsById = new Map<string, IPuppetResourceRequest>();
42
private readonly requestPublishingById = new Map<string, IResourcePublishing>();
43
44
private readonly navigationRequestIdsToLoaderId = new Map<string, string>();
45
46
private parentManager?: NetworkManager;
47
private readonly eventSubscriber = new EventSubscriber();
48
private mockNetworkRequests?: (
49
request: Protocol.Fetch.RequestPausedEvent,
50
) => Promise<Protocol.Fetch.FulfillRequestRequest>;
51
52
private readonly proxyConnectionOptions: IProxyConnectionOptions;
53
private isChromeRetainingResources = false;
54
55
constructor(
56
devtoolsSession: DevtoolsSession,
57
logger: IBoundLog,
58
proxyConnectionOptions?: IProxyConnectionOptions,
59
) {
60
super();
61
this.devtools = devtoolsSession;
62
this.logger = logger.createChild(module);
63
this.proxyConnectionOptions = proxyConnectionOptions;
64
const events = this.eventSubscriber;
65
const devtools = this.devtools;
66
events.on(devtools, 'Fetch.requestPaused', this.onRequestPaused.bind(this));
67
events.on(devtools, 'Fetch.authRequired', this.onAuthRequired.bind(this));
68
events.on(
69
devtools,
70
'Network.webSocketWillSendHandshakeRequest',
71
this.onWebsocketHandshake.bind(this),
72
);
73
events.on(devtools, 'Network.webSocketFrameReceived', this.onWebsocketFrame.bind(this, true));
74
events.on(devtools, 'Network.webSocketFrameSent', this.onWebsocketFrame.bind(this, false));
75
events.on(devtools, 'Network.requestWillBeSent', this.onNetworkRequestWillBeSent.bind(this));
76
events.on(
77
devtools,
78
'Network.requestWillBeSentExtraInfo',
79
this.onNetworkRequestWillBeSentExtraInfo.bind(this),
80
);
81
events.on(devtools, 'Network.responseReceived', this.onNetworkResponseReceived.bind(this));
82
events.on(devtools, 'Network.loadingFinished', this.onLoadingFinished.bind(this));
83
events.on(devtools, 'Network.loadingFailed', this.onLoadingFailed.bind(this));
84
events.on(
85
devtools,
86
'Network.requestServedFromCache',
87
this.onNetworkRequestServedFromCache.bind(this),
88
);
89
}
90
91
public emit<
92
K extends (keyof IPuppetNetworkEvents & string) | (keyof IPuppetNetworkEvents & symbol)
93
>(eventType: K, event?: IPuppetNetworkEvents[K]): boolean {
94
if (this.parentManager) {
95
this.parentManager.emit(eventType, event);
96
}
97
return super.emit(eventType, event);
98
}
99
100
public async initialize(): Promise<void> {
101
if (this.mockNetworkRequests) {
102
return this.devtools
103
.send('Fetch.enable', {
104
handleAuthRequests: !!this.proxyConnectionOptions?.password,
105
})
106
.catch(err => err);
107
}
108
109
const maxResourceBufferSize = this.proxyConnectionOptions?.address ? mbBytes : 5 * mbBytes; // 5mb max
110
if (maxResourceBufferSize > 0) this.isChromeRetainingResources = true;
111
112
const errors = await Promise.all([
113
this.devtools
114
.send('Network.enable', {
115
maxPostDataSize: 0,
116
maxResourceBufferSize,
117
maxTotalBufferSize: maxResourceBufferSize * 5,
118
})
119
.catch(err => err),
120
this.proxyConnectionOptions?.password
121
? this.devtools
122
.send('Fetch.enable', {
123
handleAuthRequests: true,
124
})
125
.catch(err => err)
126
: Promise.resolve(),
127
]);
128
for (const error of errors) {
129
if (error && error instanceof Error) throw error;
130
}
131
}
132
133
public async setNetworkInterceptor(
134
mockNetworkRequests: NetworkManager['mockNetworkRequests'],
135
disableSessionLogging: boolean,
136
): Promise<void> {
137
this.mockNetworkRequests = mockNetworkRequests;
138
const promises: Promise<any>[] = [];
139
if (disableSessionLogging) {
140
promises.push(this.devtools.send('Network.disable'));
141
}
142
promises.push(
143
this.devtools.send('Fetch.enable', {
144
handleAuthRequests: !!this.proxyConnectionOptions?.password,
145
}),
146
);
147
await Promise.all(promises);
148
}
149
150
public close(): void {
151
this.eventSubscriber.close();
152
this.cancelPendingEvents('NetworkManager closed');
153
}
154
155
public initializeFromParent(parentManager: NetworkManager): Promise<void> {
156
this.parentManager = parentManager;
157
return this.initialize();
158
}
159
160
private onAuthRequired(event: Protocol.Fetch.AuthRequiredEvent): void {
161
const authChallengeResponse = {
162
response: AuthChallengeResponse.Default,
163
} as Fetch.AuthChallengeResponse;
164
165
if (this.attemptedAuthentications.has(event.requestId)) {
166
authChallengeResponse.response = AuthChallengeResponse.CancelAuth;
167
} else if (this.proxyConnectionOptions?.password) {
168
this.attemptedAuthentications.add(event.requestId);
169
170
authChallengeResponse.response = AuthChallengeResponse.ProvideCredentials;
171
authChallengeResponse.username = 'puppet-chrome';
172
authChallengeResponse.password = this.proxyConnectionOptions.password;
173
}
174
this.devtools
175
.send('Fetch.continueWithAuth', {
176
requestId: event.requestId,
177
authChallengeResponse,
178
})
179
.catch(error => {
180
if (error instanceof CanceledPromiseError) return;
181
this.logger.info('NetworkManager.continueWithAuthError', {
182
error,
183
requestId: event.requestId,
184
url: event.request.url,
185
});
186
});
187
}
188
189
private async onRequestPaused(networkRequest: RequestPausedEvent): Promise<void> {
190
try {
191
if (this.mockNetworkRequests) {
192
const response = await this.mockNetworkRequests(networkRequest);
193
if (response) {
194
return await this.devtools.send('Fetch.fulfillRequest', response);
195
}
196
}
197
198
await this.devtools.send('Fetch.continueRequest', {
199
requestId: networkRequest.requestId,
200
});
201
} catch (error) {
202
if (error instanceof CanceledPromiseError) return;
203
this.logger.info('NetworkManager.continueRequestError', {
204
error,
205
requestId: networkRequest.requestId,
206
url: networkRequest.request.url,
207
});
208
}
209
210
let resource: IPuppetResourceRequest;
211
try {
212
// networkId corresponds to onNetworkRequestWillBeSent
213
resource = <IPuppetResourceRequest>{
214
browserRequestId: networkRequest.networkId ?? networkRequest.requestId,
215
resourceType: getResourceTypeForChromeValue(
216
networkRequest.resourceType,
217
networkRequest.request.method,
218
),
219
url: new URL(networkRequest.request.url),
220
method: networkRequest.request.method,
221
isSSL: networkRequest.request.url.startsWith('https'),
222
isFromRedirect: false,
223
isUpgrade: false,
224
isHttp2Push: false,
225
isServerHttp2: false,
226
requestTime: new Date(),
227
protocol: null,
228
hasUserGesture: false,
229
documentUrl: networkRequest.request.headers.Referer,
230
frameId: networkRequest.frameId,
231
};
232
} catch (error) {
233
this.logger.warn('NetworkManager.onRequestPausedError', {
234
error,
235
url: networkRequest.request.url,
236
browserRequestId: networkRequest.requestId,
237
});
238
return;
239
}
240
const existing = this.requestsById.get(resource.browserRequestId);
241
242
if (existing) {
243
if (existing.url === resource.url) {
244
resource.requestHeaders = existing.requestHeaders ?? {};
245
}
246
247
if (existing.resourceType) resource.resourceType = existing.resourceType;
248
resource.redirectedFromUrl = existing.redirectedFromUrl;
249
}
250
this.mergeRequestHeaders(resource, networkRequest.request.headers);
251
252
if (networkRequest.networkId && !this.requestsById.has(networkRequest.networkId)) {
253
this.requestsById.set(networkRequest.networkId, resource);
254
}
255
if (networkRequest.requestId !== networkRequest.networkId) {
256
this.requestsById.set(networkRequest.requestId, resource);
257
}
258
259
// requests from service workers (and others?) will never register with RequestWillBeSentEvent
260
// -- they don't have networkIds
261
this.emitResourceRequested(resource.browserRequestId);
262
}
263
264
private onNetworkRequestWillBeSent(networkRequest: RequestWillBeSentEvent): void {
265
const redirectedFromUrl = networkRequest.redirectResponse?.url;
266
267
const isNavigation =
268
networkRequest.requestId === networkRequest.loaderId && networkRequest.type === 'Document';
269
if (isNavigation) {
270
this.navigationRequestIdsToLoaderId.set(networkRequest.requestId, networkRequest.loaderId);
271
}
272
let resource: IPuppetResourceRequest;
273
try {
274
resource = <IPuppetResourceRequest>{
275
url: new URL(networkRequest.request.url),
276
isSSL: networkRequest.request.url.startsWith('https'),
277
isFromRedirect: !!redirectedFromUrl,
278
isUpgrade: false,
279
isHttp2Push: false,
280
isServerHttp2: false,
281
requestTime: new Date(networkRequest.wallTime * 1e3),
282
protocol: null,
283
browserRequestId: networkRequest.requestId,
284
resourceType: getResourceTypeForChromeValue(
285
networkRequest.type,
286
networkRequest.request.method,
287
),
288
method: networkRequest.request.method,
289
hasUserGesture: networkRequest.hasUserGesture,
290
documentUrl: networkRequest.documentURL,
291
redirectedFromUrl,
292
frameId: networkRequest.frameId,
293
};
294
} catch (error) {
295
this.logger.warn('NetworkManager.onNetworkRequestWillBeSentError', {
296
error,
297
url: networkRequest.request.url,
298
browserRequestId: networkRequest.requestId,
299
});
300
return;
301
}
302
303
const publishing = this.getPublishingForRequestId(resource.browserRequestId, true);
304
publishing.hasRequestWillBeSentEvent = true;
305
306
const existing = this.requestsById.get(resource.browserRequestId);
307
308
const isNewRedirect = redirectedFromUrl && existing && existing.url !== resource.url;
309
310
// NOTE: same requestId will be used in devtools for redirected resources
311
if (existing) {
312
if (isNewRedirect) {
313
const existingRedirects = this.redirectsById.get(resource.browserRequestId) ?? [];
314
existing.redirectedToUrl = networkRequest.request.url;
315
existing.responseHeaders = networkRequest.redirectResponse.headers;
316
existing.status = networkRequest.redirectResponse.status;
317
existing.statusMessage = networkRequest.redirectResponse.statusText;
318
this.redirectsById.set(resource.browserRequestId, [...existingRedirects, existing]);
319
publishing.isPublished = false;
320
clearTimeout(publishing.emitTimeout);
321
publishing.emitTimeout = undefined;
322
} else {
323
// preserve headers and frameId from a fetch or networkWillRequestExtraInfo
324
resource.requestHeaders = existing.requestHeaders ?? {};
325
}
326
}
327
328
this.requestsById.set(resource.browserRequestId, resource);
329
this.mergeRequestHeaders(resource, networkRequest.request.headers);
330
331
this.emitResourceRequested(resource.browserRequestId);
332
}
333
334
private onNetworkRequestWillBeSentExtraInfo(
335
networkRequest: RequestWillBeSentExtraInfoEvent,
336
): void {
337
const requestId = networkRequest.requestId;
338
let resource = this.requestsById.get(requestId);
339
if (!resource) {
340
resource = {} as any;
341
this.requestsById.set(requestId, resource);
342
}
343
344
this.mergeRequestHeaders(resource, networkRequest.headers);
345
346
const hasNetworkRequest =
347
this.requestPublishingById.get(requestId)?.hasRequestWillBeSentEvent === true;
348
if (hasNetworkRequest) {
349
this.doEmitResourceRequested(resource.browserRequestId);
350
}
351
}
352
353
private mergeRequestHeaders(
354
resource: IPuppetResourceRequest,
355
requestHeaders: RequestWillBeSentEvent['request']['headers'],
356
): void {
357
resource.requestHeaders ??= {};
358
for (const [key, value] of Object.entries(requestHeaders)) {
359
const titleKey = `${key
360
.split('-')
361
.map(x => x[0].toUpperCase() + x.slice(1))
362
.join('-')}`;
363
if (resource.requestHeaders[titleKey] && titleKey !== key) {
364
delete resource.requestHeaders[titleKey];
365
}
366
resource.requestHeaders[key] = value;
367
}
368
}
369
370
private emitResourceRequested(browserRequestId: string): void {
371
const resource = this.requestsById.get(browserRequestId);
372
if (!resource) return;
373
374
const publishing = this.getPublishingForRequestId(browserRequestId, true);
375
// if we're already waiting, go ahead and publish now
376
if (publishing.emitTimeout && !publishing.isPublished) {
377
this.doEmitResourceRequested(browserRequestId);
378
return;
379
}
380
381
// give it a small period to add extra info. no network id means it's running outside the normal "requestWillBeSent" flow
382
publishing.emitTimeout = setTimeout(
383
this.doEmitResourceRequested.bind(this),
384
200,
385
browserRequestId,
386
).unref();
387
}
388
389
private doEmitResourceRequested(browserRequestId: string): boolean {
390
const resource = this.requestsById.get(browserRequestId);
391
if (!resource) return false;
392
if (!resource.url) return false;
393
394
const publishing = this.getPublishingForRequestId(browserRequestId, true);
395
clearTimeout(publishing.emitTimeout);
396
publishing.emitTimeout = undefined;
397
398
const event = <IPuppetNetworkEvents['resource-will-be-requested']>{
399
resource,
400
isDocumentNavigation: this.navigationRequestIdsToLoaderId.has(browserRequestId),
401
frameId: resource.frameId,
402
redirectedFromUrl: resource.redirectedFromUrl,
403
loaderId: this.navigationRequestIdsToLoaderId.get(browserRequestId),
404
};
405
406
// NOTE: same requestId will be used in devtools for redirected resources
407
if (!publishing.isPublished) {
408
publishing.isPublished = true;
409
this.emit('resource-will-be-requested', event);
410
} else if (!publishing.isDetailsEmitted) {
411
publishing.isDetailsEmitted = true;
412
this.emit('resource-was-requested', event);
413
}
414
}
415
416
private onNetworkResponseReceived(event: ResponseReceivedEvent): void {
417
const { response, requestId, loaderId, frameId, type } = event;
418
419
const resource = this.requestsById.get(requestId);
420
if (resource) {
421
resource.responseHeaders = response.headers;
422
resource.status = response.status;
423
resource.statusMessage = response.statusText;
424
resource.remoteAddress = `${response.remoteIPAddress}:${response.remotePort}`;
425
resource.protocol = response.protocol;
426
resource.responseUrl = response.url;
427
resource.responseTime = new Date();
428
if (response.fromDiskCache) resource.browserServedFromCache = 'disk';
429
if (response.fromServiceWorker) resource.browserServedFromCache = 'service-worker';
430
if (response.fromPrefetchCache) resource.browserServedFromCache = 'prefetch';
431
432
if (response.requestHeaders) this.mergeRequestHeaders(resource, response.requestHeaders);
433
if (!resource.url) {
434
resource.url = new URL(response.url);
435
resource.frameId = frameId;
436
resource.browserRequestId = requestId;
437
}
438
if (!this.requestPublishingById.get(requestId)?.isPublished && resource.url?.href) {
439
this.doEmitResourceRequested(requestId);
440
}
441
}
442
443
const isNavigation = requestId === loaderId && type === 'Document';
444
if (isNavigation) {
445
this.emit('navigation-response', {
446
frameId,
447
browserRequestId: requestId,
448
status: response.status,
449
location: response.headers.location,
450
url: response.url,
451
loaderId: event.loaderId,
452
});
453
}
454
}
455
456
private onNetworkRequestServedFromCache(event: RequestServedFromCacheEvent): void {
457
const { requestId } = event;
458
const resource = this.requestsById.get(requestId);
459
if (resource) {
460
resource.browserServedFromCache = 'memory';
461
setTimeout(() => this.emitLoaded(requestId), 500).unref();
462
}
463
}
464
465
private onLoadingFailed(event: LoadingFailedEvent): void {
466
const { requestId, canceled, blockedReason, errorText } = event;
467
468
const resource = this.requestsById.get(requestId);
469
if (resource) {
470
if (!resource.url || !resource.requestTime) {
471
return;
472
}
473
474
if (canceled) resource.browserCanceled = true;
475
if (blockedReason) resource.browserBlockedReason = blockedReason;
476
if (errorText) resource.browserLoadFailure = errorText;
477
478
if (!this.requestPublishingById.get(requestId)?.isPublished) {
479
this.doEmitResourceRequested(requestId);
480
}
481
482
this.emit('resource-failed', {
483
resource,
484
});
485
this.redirectsById.delete(requestId);
486
this.requestsById.delete(requestId);
487
this.requestPublishingById.delete(requestId);
488
}
489
}
490
491
private onLoadingFinished(event: LoadingFinishedEvent): void {
492
const { requestId } = event;
493
this.emitLoaded(requestId);
494
}
495
496
private emitLoaded(id: string): void {
497
const resource = this.requestsById.get(id);
498
if (resource) {
499
if (!this.requestPublishingById.get(id)?.isPublished) this.emitResourceRequested(id);
500
this.requestsById.delete(id);
501
this.requestPublishingById.delete(id);
502
const loaderId = this.navigationRequestIdsToLoaderId.get(id);
503
if (this.redirectsById.has(id)) {
504
for (const redirect of this.redirectsById.get(id)) {
505
this.emit('resource-loaded', {
506
resource: redirect,
507
frameId: redirect.frameId,
508
loaderId,
509
// eslint-disable-next-line require-await
510
body: async () => Buffer.from(''),
511
});
512
}
513
this.redirectsById.delete(id);
514
}
515
const body = this.downloadRequestBody.bind(this, id);
516
this.emit('resource-loaded', { resource, frameId: resource.frameId, loaderId, body });
517
}
518
}
519
520
private async downloadRequestBody(requestId: string): Promise<Buffer> {
521
if (this.isChromeRetainingResources === false || !this.devtools.isConnected()) {
522
return null;
523
}
524
525
try {
526
const body = await this.devtools.send('Network.getResponseBody', {
527
requestId,
528
});
529
return Buffer.from(body.body, body.base64Encoded ? 'base64' : undefined);
530
} catch (e) {
531
return null;
532
}
533
}
534
535
private getPublishingForRequestId(id: string, createIfNull = false): IResourcePublishing {
536
const publishing = this.requestPublishingById.get(id);
537
if (publishing) return publishing;
538
if (createIfNull) {
539
this.requestPublishingById.set(id, { hasRequestWillBeSentEvent: false });
540
return this.requestPublishingById.get(id);
541
}
542
}
543
/////// WEBSOCKET EVENT HANDLERS /////////////////////////////////////////////////////////////////
544
545
private onWebsocketHandshake(handshake: WebSocketWillSendHandshakeRequestEvent): void {
546
this.emit('websocket-handshake', {
547
browserRequestId: handshake.requestId,
548
headers: handshake.request.headers,
549
});
550
}
551
552
private onWebsocketFrame(
553
isFromServer: boolean,
554
event: WebSocketFrameSentEvent | WebSocketFrameReceivedEvent,
555
): void {
556
const browserRequestId = event.requestId;
557
const { opcode, payloadData } = event.response;
558
const message = opcode === 1 ? payloadData : Buffer.from(payloadData, 'base64');
559
this.emit('websocket-frame', {
560
message,
561
browserRequestId,
562
isFromServer,
563
});
564
}
565
}
566
567