Skip to content

Commit 4975dc5

Browse files
authored
feat: [#1502] Allow fetch to be intercepted and modified (#1662)
* feat: [1502] Allow fetch to be intercepted and modified * feat: [1502] Add the possibility to intercept sync requests * fix: [1502] Correct the types for sync response hooks * feat: [1502] Allow fetch response to be intercepted * fix: [1502] Move async task manager to be ended after the intercept * fix: [1502] The interceptor not always being there
1 parent 5039b05 commit 4975dc5

File tree

8 files changed

+649
-8
lines changed

8 files changed

+649
-8
lines changed

packages/happy-dom/src/browser/types/IBrowserSettings.ts

+3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js';
22
import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js';
3+
import IFetchInterceptor from '../../fetch/types/IFetchInterceptor.js';
34

45
/**
56
* Browser settings.
@@ -40,6 +41,8 @@ export default interface IBrowserSettings {
4041
* @see https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy
4142
*/
4243
disableSameOriginPolicy: boolean;
44+
45+
interceptor?: IFetchInterceptor;
4346
};
4447

4548
/**

packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts

+3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js';
22
import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js';
3+
import IFetchInterceptor from '../../fetch/types/IFetchInterceptor.js';
34

45
export default interface IOptionalBrowserSettings {
56
/** Disables JavaScript evaluation. */
@@ -34,6 +35,8 @@ export default interface IOptionalBrowserSettings {
3435
* @see https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy
3536
*/
3637
disableSameOriginPolicy?: boolean;
38+
39+
interceptor?: IFetchInterceptor;
3740
};
3841

3942
/**

packages/happy-dom/src/fetch/Fetch.ts

+32-5
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import FetchResponseHeaderUtility from './utilities/FetchResponseHeaderUtility.j
2626
import FetchHTTPSCertificate from './certificate/FetchHTTPSCertificate.js';
2727
import { Buffer } from 'buffer';
2828
import FetchBodyUtility from './utilities/FetchBodyUtility.js';
29+
import IFetchInterceptor from './types/IFetchInterceptor.js';
2930

3031
const LAST_CHUNK = Buffer.from('0\r\n\r\n');
3132

@@ -39,7 +40,7 @@ const LAST_CHUNK = Buffer.from('0\r\n\r\n');
3940
*/
4041
export default class Fetch {
4142
private reject: (reason: Error) => void | null = null;
42-
private resolve: (value: Response | Promise<Response>) => void | null = null;
43+
private resolve: (value: Response | Promise<Response>) => Promise<void> = null;
4344
private listeners = {
4445
onSignalAbort: this.onSignalAbort.bind(this)
4546
};
@@ -50,6 +51,7 @@ export default class Fetch {
5051
private nodeResponse: IncomingMessage | null = null;
5152
private response: Response | null = null;
5253
private responseHeaders: Headers | null = null;
54+
private interceptor?: IFetchInterceptor;
5355
private request: Request;
5456
private redirectCount = 0;
5557
private disableCache: boolean;
@@ -99,6 +101,7 @@ export default class Fetch {
99101
options.disableSameOriginPolicy ??
100102
this.#browserFrame.page.context.browser.settings.fetch.disableSameOriginPolicy ??
101103
false;
104+
this.interceptor = this.#browserFrame.page.context.browser.settings.fetch.interceptor;
102105
}
103106

104107
/**
@@ -108,6 +111,15 @@ export default class Fetch {
108111
*/
109112
public async send(): Promise<Response> {
110113
FetchRequestReferrerUtility.prepareRequest(new URL(this.#window.location.href), this.request);
114+
const beforeRequestResponse = this.interceptor?.beforeAsyncRequest
115+
? await this.interceptor.beforeAsyncRequest({
116+
request: this.request,
117+
window: this.#window
118+
})
119+
: undefined;
120+
if (beforeRequestResponse instanceof Response) {
121+
return beforeRequestResponse;
122+
}
111123
FetchRequestValidationUtility.validateSchema(this.request);
112124

113125
if (this.request.signal.aborted) {
@@ -122,7 +134,14 @@ export default class Fetch {
122134
this.response = new this.#window.Response(result.buffer, {
123135
headers: { 'Content-Type': result.type }
124136
});
125-
return this.response;
137+
const interceptedResponse = this.interceptor?.afterAsyncResponse
138+
? await this.interceptor.afterAsyncResponse({
139+
window: this.#window,
140+
response: this.response,
141+
request: this.request
142+
})
143+
: undefined;
144+
return interceptedResponse instanceof Response ? interceptedResponse : this.response;
126145
}
127146

128147
// Security check for "https" to "http" requests.
@@ -365,9 +384,9 @@ export default class Fetch {
365384
throw new this.#window.Error('Fetch already sent.');
366385
}
367386

368-
this.resolve = (response: Response | Promise<Response>): void => {
387+
this.resolve = async (response: Response | Promise<Response>): Promise<void> => {
369388
// We can end up here when closing down the browser frame and there is an ongoing request.
370-
// Therefore we need to check if browserFrame.page.context is still available.
389+
// Therefore, we need to check if browserFrame.page.context is still available.
371390
if (
372391
!this.disableCache &&
373392
response instanceof Response &&
@@ -382,8 +401,16 @@ export default class Fetch {
382401
waitingForBody: !response[PropertySymbol.buffer] && !!response.body
383402
});
384403
}
404+
405+
const interceptedResponse = this.interceptor?.afterAsyncResponse
406+
? await this.interceptor.afterAsyncResponse({
407+
window: this.#window,
408+
response: await response,
409+
request: this.request
410+
})
411+
: undefined;
385412
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);
386-
resolve(response);
413+
resolve(interceptedResponse instanceof Response ? interceptedResponse : response);
387414
};
388415
this.reject = (error: Error): void => {
389416
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);

packages/happy-dom/src/fetch/SyncFetch.ts

+29-2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import Zlib from 'zlib';
2020
import FetchResponseRedirectUtility from './utilities/FetchResponseRedirectUtility.js';
2121
import FetchCORSUtility from './utilities/FetchCORSUtility.js';
2222
import Fetch from './Fetch.js';
23+
import IFetchInterceptor from './types/IFetchInterceptor.js';
2324

2425
interface ISyncHTTPResponse {
2526
error: string;
@@ -39,6 +40,7 @@ export default class SyncFetch {
3940
private redirectCount = 0;
4041
private disableCache: boolean;
4142
private disableSameOriginPolicy: boolean;
43+
private interceptor?: IFetchInterceptor;
4244
#browserFrame: IBrowserFrame;
4345
#window: BrowserWindow;
4446
#unfilteredHeaders: Headers | null = null;
@@ -84,6 +86,7 @@ export default class SyncFetch {
8486
options.disableSameOriginPolicy ??
8587
this.#browserFrame.page.context.browser.settings.fetch.disableSameOriginPolicy ??
8688
false;
89+
this.interceptor = this.#browserFrame.page.context.browser.settings.fetch.interceptor;
8790
}
8891

8992
/**
@@ -93,6 +96,15 @@ export default class SyncFetch {
9396
*/
9497
public send(): ISyncResponse {
9598
FetchRequestReferrerUtility.prepareRequest(new URL(this.#window.location.href), this.request);
99+
const beforeRequestResponse = this.interceptor?.beforeSyncRequest
100+
? this.interceptor.beforeSyncRequest({
101+
request: this.request,
102+
window: this.#window
103+
})
104+
: undefined;
105+
if (typeof beforeRequestResponse === 'object') {
106+
return beforeRequestResponse;
107+
}
96108
FetchRequestValidationUtility.validateSchema(this.request);
97109

98110
if (this.request.signal.aborted) {
@@ -104,7 +116,7 @@ export default class SyncFetch {
104116

105117
if (this.request[PropertySymbol.url].protocol === 'data:') {
106118
const result = DataURIParser.parse(this.request.url);
107-
return {
119+
const response = {
108120
status: 200,
109121
statusText: 'OK',
110122
ok: true,
@@ -113,6 +125,14 @@ export default class SyncFetch {
113125
headers: new Headers({ 'Content-Type': result.type }),
114126
body: result.buffer
115127
};
128+
const interceptedResponse = this.interceptor?.afterSyncResponse
129+
? this.interceptor.afterSyncResponse({
130+
window: this.#window,
131+
response,
132+
request: this.request
133+
})
134+
: undefined;
135+
return typeof interceptedResponse === 'object' ? interceptedResponse : response;
116136
}
117137

118138
// Security check for "https" to "http" requests.
@@ -416,7 +436,14 @@ export default class SyncFetch {
416436
});
417437
}
418438

419-
return redirectedResponse;
439+
const interceptedResponse = this.interceptor?.afterSyncResponse
440+
? this.interceptor.afterSyncResponse({
441+
window: this.#window,
442+
response: redirectedResponse,
443+
request: this.request
444+
})
445+
: undefined;
446+
return typeof interceptedResponse === 'object' ? interceptedResponse : redirectedResponse;
420447
}
421448

422449
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import Request from '../Request.js';
2+
import BrowserWindow from '../../window/BrowserWindow.js';
3+
import Response from '../Response.js';
4+
import ISyncResponse from './ISyncResponse.js';
5+
6+
export default interface IFetchInterceptor {
7+
/**
8+
* Hook dispatched before making an async request.
9+
* It can be used for modifying the request, providing a response without making a request or for logging.
10+
*
11+
* @param context Contains the request and the window from where the request was made.
12+
*
13+
* @returns Promise that can resolve to a response to be used instead of sending out the response.
14+
*/
15+
beforeAsyncRequest?: (context: {
16+
request: Request;
17+
window: BrowserWindow;
18+
}) => Promise<Response | void>;
19+
20+
/**
21+
* Hook dispatched before making an sync request.
22+
* It can be used for modifying the request, providing a response without making a request or for logging.
23+
*
24+
* @param context Contains the request and the window from where the request was made.
25+
*
26+
* @returns Promise that can resolve to a response to be used instead of sending out the response.
27+
*/
28+
beforeSyncRequest?: (context: {
29+
request: Request;
30+
window: BrowserWindow;
31+
}) => ISyncResponse | void;
32+
33+
/**
34+
* Hook dispatched after receiving an async response.
35+
* It can be used for modifying or replacing the response and logging.
36+
*
37+
* @param context Contains the request, response and the window from where the request was made.
38+
*
39+
* @returns Promise that can resolve to a response to be used instead of sending out the response.
40+
*/
41+
afterAsyncResponse?: (context: {
42+
request: Request;
43+
response: Response;
44+
window: BrowserWindow;
45+
}) => Promise<Response | void>;
46+
47+
/**
48+
* Hook dispatched after receiving a sync response.
49+
* It can be used for modifying or replacing the response and logging
50+
*
51+
* @param context Contains the request, response and the window from where the request was made.
52+
*
53+
* @returns Promise that can resolve to a response to be used instead of sending out the response.
54+
*/
55+
afterSyncResponse?: (context: {
56+
request: Request;
57+
response: ISyncResponse;
58+
window: BrowserWindow;
59+
}) => ISyncResponse | void;
60+
}

packages/happy-dom/src/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ import AbortSignal from './fetch/AbortSignal.js';
5757
import Headers from './fetch/Headers.js';
5858
import Request from './fetch/Request.js';
5959
import Response from './fetch/Response.js';
60+
import IFetchInterceptor from './fetch/types/IFetchInterceptor.js';
61+
import ISyncResponse from './fetch/types/ISyncResponse.js';
6062
import Blob from './file/Blob.js';
6163
import File from './file/File.js';
6264
import FileReader from './file/FileReader.js';
@@ -206,6 +208,8 @@ import type ITouchEventInit from './event/events/ITouchEventInit.js';
206208
import type IWheelEventInit from './event/events/IWheelEventInit.js';
207209

208210
export type {
211+
IFetchInterceptor,
212+
ISyncResponse,
209213
IAnimationEventInit,
210214
IBrowser,
211215
IBrowserContext,

0 commit comments

Comments
 (0)