1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 | import type {Protocol} from 'devtools-protocol';
|
7 |
|
8 | import type {ProtocolError} from '../common/Errors.js';
|
9 | import {debugError, isString} from '../common/util.js';
|
10 | import {assert} from '../util/assert.js';
|
11 | import {typedArrayToBase64} from '../util/encoding.js';
|
12 |
|
13 | import type {CDPSession} from './CDPSession.js';
|
14 | import type {Frame} from './Frame.js';
|
15 | import type {HTTPResponse} from './HTTPResponse.js';
|
16 |
|
17 |
|
18 |
|
19 |
|
20 | export interface ContinueRequestOverrides {
|
21 | |
22 |
|
23 |
|
24 | url?: string;
|
25 | method?: string;
|
26 | postData?: string;
|
27 | headers?: Record<string, string>;
|
28 | }
|
29 |
|
30 |
|
31 |
|
32 |
|
33 | export interface InterceptResolutionState {
|
34 | action: InterceptResolutionAction;
|
35 | priority?: number;
|
36 | }
|
37 |
|
38 |
|
39 |
|
40 |
|
41 |
|
42 |
|
43 | export interface ResponseForRequest {
|
44 | status: number;
|
45 | |
46 |
|
47 |
|
48 |
|
49 |
|
50 |
|
51 |
|
52 |
|
53 | headers: Record<string, string | string[] | unknown>;
|
54 | contentType: string;
|
55 | body: string | Uint8Array;
|
56 | }
|
57 |
|
58 |
|
59 |
|
60 |
|
61 |
|
62 |
|
63 | export type ResourceType = Lowercase<Protocol.Network.ResourceType>;
|
64 |
|
65 |
|
66 |
|
67 |
|
68 |
|
69 |
|
70 | export const DEFAULT_INTERCEPT_RESOLUTION_PRIORITY = 0;
|
71 |
|
72 |
|
73 |
|
74 |
|
75 |
|
76 |
|
77 |
|
78 |
|
79 |
|
80 |
|
81 |
|
82 |
|
83 |
|
84 |
|
85 |
|
86 |
|
87 |
|
88 |
|
89 |
|
90 |
|
91 |
|
92 |
|
93 |
|
94 |
|
95 |
|
96 |
|
97 |
|
98 |
|
99 |
|
100 |
|
101 |
|
102 |
|
103 |
|
104 | export abstract class HTTPRequest {
|
105 | |
106 |
|
107 |
|
108 | abstract get id(): string;
|
109 |
|
110 | |
111 |
|
112 |
|
113 | _interceptionId: string | undefined;
|
114 | |
115 |
|
116 |
|
117 | _failureText: string | null = null;
|
118 | |
119 |
|
120 |
|
121 | _response: HTTPResponse | null = null;
|
122 | |
123 |
|
124 |
|
125 | _fromMemoryCache = false;
|
126 | |
127 |
|
128 |
|
129 | _redirectChain: HTTPRequest[] = [];
|
130 |
|
131 | |
132 |
|
133 |
|
134 | protected interception: {
|
135 | enabled: boolean;
|
136 | handled: boolean;
|
137 | handlers: Array<() => void | PromiseLike<any>>;
|
138 | resolutionState: InterceptResolutionState;
|
139 | requestOverrides: ContinueRequestOverrides;
|
140 | response: Partial<ResponseForRequest> | null;
|
141 | abortReason: Protocol.Network.ErrorReason | null;
|
142 | } = {
|
143 | enabled: false,
|
144 | handled: false,
|
145 | handlers: [],
|
146 | resolutionState: {
|
147 | action: InterceptResolutionAction.None,
|
148 | },
|
149 | requestOverrides: {},
|
150 | response: null,
|
151 | abortReason: null,
|
152 | };
|
153 |
|
154 | |
155 |
|
156 |
|
157 |
|
158 |
|
159 | abstract get client(): CDPSession;
|
160 |
|
161 | |
162 |
|
163 |
|
164 | constructor() {}
|
165 |
|
166 | |
167 |
|
168 |
|
169 | abstract url(): string;
|
170 |
|
171 | |
172 |
|
173 |
|
174 |
|
175 |
|
176 | continueRequestOverrides(): ContinueRequestOverrides {
|
177 | assert(this.interception.enabled, 'Request Interception is not enabled!');
|
178 | return this.interception.requestOverrides;
|
179 | }
|
180 |
|
181 | |
182 |
|
183 |
|
184 |
|
185 | responseForRequest(): Partial<ResponseForRequest> | null {
|
186 | assert(this.interception.enabled, 'Request Interception is not enabled!');
|
187 | return this.interception.response;
|
188 | }
|
189 |
|
190 | |
191 |
|
192 |
|
193 | abortErrorReason(): Protocol.Network.ErrorReason | null {
|
194 | assert(this.interception.enabled, 'Request Interception is not enabled!');
|
195 | return this.interception.abortReason;
|
196 | }
|
197 |
|
198 | |
199 |
|
200 |
|
201 |
|
202 |
|
203 |
|
204 |
|
205 |
|
206 |
|
207 |
|
208 |
|
209 | interceptResolutionState(): InterceptResolutionState {
|
210 | if (!this.interception.enabled) {
|
211 | return {action: InterceptResolutionAction.Disabled};
|
212 | }
|
213 | if (this.interception.handled) {
|
214 | return {action: InterceptResolutionAction.AlreadyHandled};
|
215 | }
|
216 | return {...this.interception.resolutionState};
|
217 | }
|
218 |
|
219 | |
220 |
|
221 |
|
222 |
|
223 | isInterceptResolutionHandled(): boolean {
|
224 | return this.interception.handled;
|
225 | }
|
226 |
|
227 | |
228 |
|
229 |
|
230 |
|
231 |
|
232 |
|
233 | enqueueInterceptAction(
|
234 | pendingHandler: () => void | PromiseLike<unknown>,
|
235 | ): void {
|
236 | this.interception.handlers.push(pendingHandler);
|
237 | }
|
238 |
|
239 | |
240 |
|
241 |
|
242 | abstract _abort(
|
243 | errorReason: Protocol.Network.ErrorReason | null,
|
244 | ): Promise<void>;
|
245 |
|
246 | |
247 |
|
248 |
|
249 | abstract _respond(response: Partial<ResponseForRequest>): Promise<void>;
|
250 |
|
251 | |
252 |
|
253 |
|
254 | abstract _continue(overrides: ContinueRequestOverrides): Promise<void>;
|
255 |
|
256 | |
257 |
|
258 |
|
259 |
|
260 | async finalizeInterceptions(): Promise<void> {
|
261 | await this.interception.handlers.reduce((promiseChain, interceptAction) => {
|
262 | return promiseChain.then(interceptAction);
|
263 | }, Promise.resolve());
|
264 | this.interception.handlers = [];
|
265 | const {action} = this.interceptResolutionState();
|
266 | switch (action) {
|
267 | case 'abort':
|
268 | return await this._abort(this.interception.abortReason);
|
269 | case 'respond':
|
270 | if (this.interception.response === null) {
|
271 | throw new Error('Response is missing for the interception');
|
272 | }
|
273 | return await this._respond(this.interception.response);
|
274 | case 'continue':
|
275 | return await this._continue(this.interception.requestOverrides);
|
276 | }
|
277 | }
|
278 |
|
279 | |
280 |
|
281 |
|
282 |
|
283 | abstract resourceType(): ResourceType;
|
284 |
|
285 | |
286 |
|
287 |
|
288 | abstract method(): string;
|
289 |
|
290 | |
291 |
|
292 |
|
293 | abstract postData(): string | undefined;
|
294 |
|
295 | |
296 |
|
297 |
|
298 |
|
299 |
|
300 |
|
301 | abstract hasPostData(): boolean;
|
302 |
|
303 | |
304 |
|
305 |
|
306 | abstract fetchPostData(): Promise<string | undefined>;
|
307 |
|
308 | |
309 |
|
310 |
|
311 |
|
312 | abstract headers(): Record<string, string>;
|
313 |
|
314 | |
315 |
|
316 |
|
317 |
|
318 | abstract response(): HTTPResponse | null;
|
319 |
|
320 | |
321 |
|
322 |
|
323 |
|
324 | abstract frame(): Frame | null;
|
325 |
|
326 | |
327 |
|
328 |
|
329 | abstract isNavigationRequest(): boolean;
|
330 |
|
331 | |
332 |
|
333 |
|
334 | abstract initiator(): Protocol.Network.Initiator | undefined;
|
335 |
|
336 | |
337 |
|
338 |
|
339 |
|
340 |
|
341 |
|
342 |
|
343 |
|
344 |
|
345 |
|
346 |
|
347 |
|
348 |
|
349 |
|
350 |
|
351 |
|
352 |
|
353 |
|
354 |
|
355 |
|
356 |
|
357 |
|
358 |
|
359 |
|
360 |
|
361 |
|
362 |
|
363 | abstract redirectChain(): HTTPRequest[];
|
364 |
|
365 | |
366 |
|
367 |
|
368 |
|
369 |
|
370 |
|
371 |
|
372 |
|
373 |
|
374 |
|
375 |
|
376 |
|
377 |
|
378 |
|
379 |
|
380 |
|
381 |
|
382 |
|
383 |
|
384 |
|
385 | abstract failure(): {errorText: string} | null;
|
386 |
|
387 | #canBeIntercepted(): boolean {
|
388 | return !this.url().startsWith('data:') && !this._fromMemoryCache;
|
389 | }
|
390 |
|
391 | |
392 |
|
393 |
|
394 |
|
395 |
|
396 |
|
397 |
|
398 |
|
399 |
|
400 |
|
401 |
|
402 |
|
403 |
|
404 |
|
405 |
|
406 |
|
407 |
|
408 |
|
409 |
|
410 |
|
411 |
|
412 |
|
413 |
|
414 |
|
415 |
|
416 |
|
417 |
|
418 |
|
419 | async continue(
|
420 | overrides: ContinueRequestOverrides = {},
|
421 | priority?: number,
|
422 | ): Promise<void> {
|
423 | if (!this.#canBeIntercepted()) {
|
424 | return;
|
425 | }
|
426 | assert(this.interception.enabled, 'Request Interception is not enabled!');
|
427 | assert(!this.interception.handled, 'Request is already handled!');
|
428 | if (priority === undefined) {
|
429 | return await this._continue(overrides);
|
430 | }
|
431 | this.interception.requestOverrides = overrides;
|
432 | if (
|
433 | this.interception.resolutionState.priority === undefined ||
|
434 | priority > this.interception.resolutionState.priority
|
435 | ) {
|
436 | this.interception.resolutionState = {
|
437 | action: InterceptResolutionAction.Continue,
|
438 | priority,
|
439 | };
|
440 | return;
|
441 | }
|
442 | if (priority === this.interception.resolutionState.priority) {
|
443 | if (
|
444 | this.interception.resolutionState.action === 'abort' ||
|
445 | this.interception.resolutionState.action === 'respond'
|
446 | ) {
|
447 | return;
|
448 | }
|
449 | this.interception.resolutionState.action =
|
450 | InterceptResolutionAction.Continue;
|
451 | }
|
452 | return;
|
453 | }
|
454 |
|
455 | |
456 |
|
457 |
|
458 |
|
459 |
|
460 |
|
461 |
|
462 |
|
463 |
|
464 |
|
465 |
|
466 |
|
467 |
|
468 |
|
469 |
|
470 |
|
471 |
|
472 |
|
473 |
|
474 |
|
475 |
|
476 |
|
477 |
|
478 |
|
479 |
|
480 |
|
481 |
|
482 |
|
483 |
|
484 |
|
485 |
|
486 |
|
487 | async respond(
|
488 | response: Partial<ResponseForRequest>,
|
489 | priority?: number,
|
490 | ): Promise<void> {
|
491 | if (!this.#canBeIntercepted()) {
|
492 | return;
|
493 | }
|
494 | assert(this.interception.enabled, 'Request Interception is not enabled!');
|
495 | assert(!this.interception.handled, 'Request is already handled!');
|
496 | if (priority === undefined) {
|
497 | return await this._respond(response);
|
498 | }
|
499 | this.interception.response = response;
|
500 | if (
|
501 | this.interception.resolutionState.priority === undefined ||
|
502 | priority > this.interception.resolutionState.priority
|
503 | ) {
|
504 | this.interception.resolutionState = {
|
505 | action: InterceptResolutionAction.Respond,
|
506 | priority,
|
507 | };
|
508 | return;
|
509 | }
|
510 | if (priority === this.interception.resolutionState.priority) {
|
511 | if (this.interception.resolutionState.action === 'abort') {
|
512 | return;
|
513 | }
|
514 | this.interception.resolutionState.action =
|
515 | InterceptResolutionAction.Respond;
|
516 | }
|
517 | }
|
518 |
|
519 | |
520 |
|
521 |
|
522 |
|
523 |
|
524 |
|
525 |
|
526 |
|
527 |
|
528 |
|
529 |
|
530 |
|
531 |
|
532 |
|
533 | async abort(
|
534 | errorCode: ErrorCode = 'failed',
|
535 | priority?: number,
|
536 | ): Promise<void> {
|
537 | if (!this.#canBeIntercepted()) {
|
538 | return;
|
539 | }
|
540 | const errorReason = errorReasons[errorCode];
|
541 | assert(errorReason, 'Unknown error code: ' + errorCode);
|
542 | assert(this.interception.enabled, 'Request Interception is not enabled!');
|
543 | assert(!this.interception.handled, 'Request is already handled!');
|
544 | if (priority === undefined) {
|
545 | return await this._abort(errorReason);
|
546 | }
|
547 | this.interception.abortReason = errorReason;
|
548 | if (
|
549 | this.interception.resolutionState.priority === undefined ||
|
550 | priority >= this.interception.resolutionState.priority
|
551 | ) {
|
552 | this.interception.resolutionState = {
|
553 | action: InterceptResolutionAction.Abort,
|
554 | priority,
|
555 | };
|
556 | return;
|
557 | }
|
558 | }
|
559 |
|
560 | |
561 |
|
562 |
|
563 | static getResponse(body: string | Uint8Array): {
|
564 | contentLength: number;
|
565 | base64: string;
|
566 | } {
|
567 |
|
568 | const byteBody: Uint8Array = isString(body)
|
569 | ? new TextEncoder().encode(body)
|
570 | : body;
|
571 |
|
572 | return {
|
573 | contentLength: byteBody.byteLength,
|
574 | base64: typedArrayToBase64(byteBody),
|
575 | };
|
576 | }
|
577 | }
|
578 |
|
579 |
|
580 |
|
581 |
|
582 | export enum InterceptResolutionAction {
|
583 | Abort = 'abort',
|
584 | Respond = 'respond',
|
585 | Continue = 'continue',
|
586 | Disabled = 'disabled',
|
587 | None = 'none',
|
588 | AlreadyHandled = 'already-handled',
|
589 | }
|
590 |
|
591 |
|
592 |
|
593 |
|
594 | export type ErrorCode =
|
595 | | 'aborted'
|
596 | | 'accessdenied'
|
597 | | 'addressunreachable'
|
598 | | 'blockedbyclient'
|
599 | | 'blockedbyresponse'
|
600 | | 'connectionaborted'
|
601 | | 'connectionclosed'
|
602 | | 'connectionfailed'
|
603 | | 'connectionrefused'
|
604 | | 'connectionreset'
|
605 | | 'internetdisconnected'
|
606 | | 'namenotresolved'
|
607 | | 'timedout'
|
608 | | 'failed';
|
609 |
|
610 |
|
611 |
|
612 |
|
613 | export type ActionResult = 'continue' | 'abort' | 'respond';
|
614 |
|
615 |
|
616 |
|
617 |
|
618 | export function headersArray(
|
619 | headers: Record<string, string | string[]>,
|
620 | ): Array<{name: string; value: string}> {
|
621 | const result = [];
|
622 | for (const name in headers) {
|
623 | const value = headers[name];
|
624 |
|
625 | if (!Object.is(value, undefined)) {
|
626 | const values = Array.isArray(value) ? value : [value];
|
627 |
|
628 | result.push(
|
629 | ...values.map(value => {
|
630 | return {name, value: value + ''};
|
631 | }),
|
632 | );
|
633 | }
|
634 | }
|
635 | return result;
|
636 | }
|
637 |
|
638 |
|
639 |
|
640 |
|
641 |
|
642 |
|
643 |
|
644 |
|
645 | export const STATUS_TEXTS: Record<string, string> = {
|
646 | '100': 'Continue',
|
647 | '101': 'Switching Protocols',
|
648 | '102': 'Processing',
|
649 | '103': 'Early Hints',
|
650 | '200': 'OK',
|
651 | '201': 'Created',
|
652 | '202': 'Accepted',
|
653 | '203': 'Non-Authoritative Information',
|
654 | '204': 'No Content',
|
655 | '205': 'Reset Content',
|
656 | '206': 'Partial Content',
|
657 | '207': 'Multi-Status',
|
658 | '208': 'Already Reported',
|
659 | '226': 'IM Used',
|
660 | '300': 'Multiple Choices',
|
661 | '301': 'Moved Permanently',
|
662 | '302': 'Found',
|
663 | '303': 'See Other',
|
664 | '304': 'Not Modified',
|
665 | '305': 'Use Proxy',
|
666 | '306': 'Switch Proxy',
|
667 | '307': 'Temporary Redirect',
|
668 | '308': 'Permanent Redirect',
|
669 | '400': 'Bad Request',
|
670 | '401': 'Unauthorized',
|
671 | '402': 'Payment Required',
|
672 | '403': 'Forbidden',
|
673 | '404': 'Not Found',
|
674 | '405': 'Method Not Allowed',
|
675 | '406': 'Not Acceptable',
|
676 | '407': 'Proxy Authentication Required',
|
677 | '408': 'Request Timeout',
|
678 | '409': 'Conflict',
|
679 | '410': 'Gone',
|
680 | '411': 'Length Required',
|
681 | '412': 'Precondition Failed',
|
682 | '413': 'Payload Too Large',
|
683 | '414': 'URI Too Long',
|
684 | '415': 'Unsupported Media Type',
|
685 | '416': 'Range Not Satisfiable',
|
686 | '417': 'Expectation Failed',
|
687 | '418': "I'm a teapot",
|
688 | '421': 'Misdirected Request',
|
689 | '422': 'Unprocessable Entity',
|
690 | '423': 'Locked',
|
691 | '424': 'Failed Dependency',
|
692 | '425': 'Too Early',
|
693 | '426': 'Upgrade Required',
|
694 | '428': 'Precondition Required',
|
695 | '429': 'Too Many Requests',
|
696 | '431': 'Request Header Fields Too Large',
|
697 | '451': 'Unavailable For Legal Reasons',
|
698 | '500': 'Internal Server Error',
|
699 | '501': 'Not Implemented',
|
700 | '502': 'Bad Gateway',
|
701 | '503': 'Service Unavailable',
|
702 | '504': 'Gateway Timeout',
|
703 | '505': 'HTTP Version Not Supported',
|
704 | '506': 'Variant Also Negotiates',
|
705 | '507': 'Insufficient Storage',
|
706 | '508': 'Loop Detected',
|
707 | '510': 'Not Extended',
|
708 | '511': 'Network Authentication Required',
|
709 | } as const;
|
710 |
|
711 | const errorReasons: Record<ErrorCode, Protocol.Network.ErrorReason> = {
|
712 | aborted: 'Aborted',
|
713 | accessdenied: 'AccessDenied',
|
714 | addressunreachable: 'AddressUnreachable',
|
715 | blockedbyclient: 'BlockedByClient',
|
716 | blockedbyresponse: 'BlockedByResponse',
|
717 | connectionaborted: 'ConnectionAborted',
|
718 | connectionclosed: 'ConnectionClosed',
|
719 | connectionfailed: 'ConnectionFailed',
|
720 | connectionrefused: 'ConnectionRefused',
|
721 | connectionreset: 'ConnectionReset',
|
722 | internetdisconnected: 'InternetDisconnected',
|
723 | namenotresolved: 'NameNotResolved',
|
724 | timedout: 'TimedOut',
|
725 | failed: 'Failed',
|
726 | } as const;
|
727 |
|
728 |
|
729 |
|
730 |
|
731 | export function handleError(error: ProtocolError): void {
|
732 |
|
733 |
|
734 | if (
|
735 | error.originalMessage.includes('Invalid header') ||
|
736 | error.originalMessage.includes('Expected "header"') ||
|
737 |
|
738 | error.originalMessage.includes('invalid argument')
|
739 | ) {
|
740 | throw error;
|
741 | }
|
742 |
|
743 |
|
744 |
|
745 | debugError(error);
|
746 | }
|