Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [#1502] Allow fetch to be intercepted and modified #1662

3 changes: 3 additions & 0 deletions packages/happy-dom/src/browser/types/IBrowserSettings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js';
import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js';
import IFetchInterceptor from '../../fetch/types/IFetchInterceptor.js';

/**
* Browser settings.
Expand Down Expand Up @@ -40,6 +41,8 @@ export default interface IBrowserSettings {
* @see https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy
*/
disableSameOriginPolicy: boolean;

interceptor?: IFetchInterceptor;
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js';
import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js';
import IFetchInterceptor from '../../fetch/types/IFetchInterceptor.js';

export default interface IOptionalBrowserSettings {
/** Disables JavaScript evaluation. */
Expand Down Expand Up @@ -34,6 +35,8 @@ export default interface IOptionalBrowserSettings {
* @see https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy
*/
disableSameOriginPolicy?: boolean;

interceptor?: IFetchInterceptor;
};

/**
Expand Down
36 changes: 31 additions & 5 deletions packages/happy-dom/src/fetch/Fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import FetchResponseHeaderUtility from './utilities/FetchResponseHeaderUtility.j
import FetchHTTPSCertificate from './certificate/FetchHTTPSCertificate.js';
import { Buffer } from 'buffer';
import FetchBodyUtility from './utilities/FetchBodyUtility.js';
import IFetchInterceptor from './types/IFetchInterceptor.js';

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

Expand All @@ -39,7 +40,7 @@ const LAST_CHUNK = Buffer.from('0\r\n\r\n');
*/
export default class Fetch {
private reject: (reason: Error) => void | null = null;
private resolve: (value: Response | Promise<Response>) => void | null = null;
private resolve: (value: Response | Promise<Response>) => Promise<void> = null;
Copy link
Contributor Author

@OlaviSau OlaviSau Jan 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this cause any issues? @capricorn86

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is only used internally, so it should be fine

private listeners = {
onSignalAbort: this.onSignalAbort.bind(this)
};
Expand All @@ -50,6 +51,7 @@ export default class Fetch {
private nodeResponse: IncomingMessage | null = null;
private response: Response | null = null;
private responseHeaders: Headers | null = null;
private interceptor?: IFetchInterceptor;
private request: Request;
private redirectCount = 0;
private disableCache: boolean;
Expand Down Expand Up @@ -99,6 +101,7 @@ export default class Fetch {
options.disableSameOriginPolicy ??
this.#browserFrame.page.context.browser.settings.fetch.disableSameOriginPolicy ??
false;
this.interceptor = this.#browserFrame.page.context.browser.settings.fetch.interceptor;
}

/**
Expand All @@ -108,6 +111,15 @@ export default class Fetch {
*/
public async send(): Promise<Response> {
FetchRequestReferrerUtility.prepareRequest(new URL(this.#window.location.href), this.request);
const beforeRequestResponse = this.interceptor?.beforeAsyncRequest
? await this.interceptor.beforeAsyncRequest({
request: this.request,
window: this.#window
})
: undefined;
if (beforeRequestResponse instanceof Response) {
return beforeRequestResponse;
}
FetchRequestValidationUtility.validateSchema(this.request);

if (this.request.signal.aborted) {
Expand All @@ -122,7 +134,14 @@ export default class Fetch {
this.response = new this.#window.Response(result.buffer, {
headers: { 'Content-Type': result.type }
});
return this.response;
const interceptedResponse = this.interceptor.afterAsyncResponse
? await this.interceptor.afterAsyncResponse({
window: this.#window,
response: this.response,
request: this.request
})
: undefined;
return interceptedResponse instanceof Response ? interceptedResponse : this.response;
}

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

this.resolve = (response: Response | Promise<Response>): void => {
this.resolve = async (response: Response | Promise<Response>): Promise<void> => {
// We can end up here when closing down the browser frame and there is an ongoing request.
// Therefore we need to check if browserFrame.page.context is still available.
// Therefore, we need to check if browserFrame.page.context is still available.
if (
!this.disableCache &&
response instanceof Response &&
Expand All @@ -383,7 +402,14 @@ export default class Fetch {
});
}
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to end the task for the "asyncTaskManager" after the intercepted response has been handled

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed it :)

resolve(response);
const interceptedResponse = this.interceptor.afterAsyncResponse
? await this.interceptor.afterAsyncResponse({
window: this.#window,
response: await response,
request: this.request
})
: undefined;
resolve(interceptedResponse instanceof Response ? interceptedResponse : response);
};
this.reject = (error: Error): void => {
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);
Expand Down
31 changes: 29 additions & 2 deletions packages/happy-dom/src/fetch/SyncFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import Zlib from 'zlib';
import FetchResponseRedirectUtility from './utilities/FetchResponseRedirectUtility.js';
import FetchCORSUtility from './utilities/FetchCORSUtility.js';
import Fetch from './Fetch.js';
import IFetchInterceptor from './types/IFetchInterceptor.js';

interface ISyncHTTPResponse {
error: string;
Expand All @@ -39,6 +40,7 @@ export default class SyncFetch {
private redirectCount = 0;
private disableCache: boolean;
private disableSameOriginPolicy: boolean;
private interceptor?: IFetchInterceptor;
#browserFrame: IBrowserFrame;
#window: BrowserWindow;
#unfilteredHeaders: Headers | null = null;
Expand Down Expand Up @@ -84,6 +86,7 @@ export default class SyncFetch {
options.disableSameOriginPolicy ??
this.#browserFrame.page.context.browser.settings.fetch.disableSameOriginPolicy ??
false;
this.interceptor = this.#browserFrame.page.context.browser.settings.fetch.interceptor;
}

/**
Expand All @@ -93,6 +96,15 @@ export default class SyncFetch {
*/
public send(): ISyncResponse {
FetchRequestReferrerUtility.prepareRequest(new URL(this.#window.location.href), this.request);
const beforeRequestResponse = this.interceptor?.beforeSyncRequest
? this.interceptor.beforeSyncRequest({
request: this.request,
window: this.#window
})
: undefined;
if (typeof beforeRequestResponse === 'object') {
return beforeRequestResponse;
}
FetchRequestValidationUtility.validateSchema(this.request);

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

if (this.request[PropertySymbol.url].protocol === 'data:') {
const result = DataURIParser.parse(this.request.url);
return {
const response = {
status: 200,
statusText: 'OK',
ok: true,
Expand All @@ -113,6 +125,14 @@ export default class SyncFetch {
headers: new Headers({ 'Content-Type': result.type }),
body: result.buffer
};
const interceptedResponse = this.interceptor.afterSyncResponse
? this.interceptor.afterSyncResponse({
window: this.#window,
response,
request: this.request
})
: undefined;
return typeof interceptedResponse === 'object' ? interceptedResponse : response;
}

// Security check for "https" to "http" requests.
Expand Down Expand Up @@ -416,7 +436,14 @@ export default class SyncFetch {
});
}

return redirectedResponse;
const interceptedResponse = this.interceptor.afterSyncResponse
? this.interceptor.afterSyncResponse({
window: this.#window,
response: redirectedResponse,
request: this.request
})
: undefined;
return typeof interceptedResponse === 'object' ? interceptedResponse : redirectedResponse;
}

/**
Expand Down
60 changes: 60 additions & 0 deletions packages/happy-dom/src/fetch/types/IFetchInterceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Request from '../Request.js';
import BrowserWindow from '../../window/BrowserWindow.js';
import Response from '../Response.js';
import ISyncResponse from './ISyncResponse.js';

export default interface IFetchInterceptor {
/**
* Hook dispatched before making an async request.
* It can be used for modifying the request, providing a response without making a request or for logging.
*
* @param context Contains the request and the window from where the request was made.
*
* @returns Promise that can resolve to a response to be used instead of sending out the response.
*/
beforeAsyncRequest?: (context: {
request: Request;
window: BrowserWindow;
}) => Promise<Response | void>;

/**
* Hook dispatched before making an sync request.
* It can be used for modifying the request, providing a response without making a request or for logging.
*
* @param context Contains the request and the window from where the request was made.
*
* @returns Promise that can resolve to a response to be used instead of sending out the response.
*/
beforeSyncRequest?: (context: {
request: Request;
window: BrowserWindow;
}) => ISyncResponse | void;

/**
* Hook dispatched after receiving an async response.
* It can be used for modifying or replacing the response and logging.
*
* @param context Contains the request, response and the window from where the request was made.
*
* @returns Promise that can resolve to a response to be used instead of sending out the response.
*/
afterAsyncResponse?: (context: {
request: Request;
response: Response;
window: BrowserWindow;
}) => Promise<Response | void>;

/**
* Hook dispatched after receiving a sync response.
* It can be used for modifying or replacing the response and logging
*
* @param context Contains the request, response and the window from where the request was made.
*
* @returns Promise that can resolve to a response to be used instead of sending out the response.
*/
afterSyncResponse?: (context: {
request: Request;
response: ISyncResponse;
window: BrowserWindow;
}) => ISyncResponse | void;
}
4 changes: 4 additions & 0 deletions packages/happy-dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ import AbortSignal from './fetch/AbortSignal.js';
import Headers from './fetch/Headers.js';
import Request from './fetch/Request.js';
import Response from './fetch/Response.js';
import IFetchInterceptor from './fetch/types/IFetchInterceptor.js';
import ISyncResponse from './fetch/types/ISyncResponse.js';
import Blob from './file/Blob.js';
import File from './file/File.js';
import FileReader from './file/FileReader.js';
Expand Down Expand Up @@ -206,6 +208,8 @@ import type ITouchEventInit from './event/events/ITouchEventInit.js';
import type IWheelEventInit from './event/events/IWheelEventInit.js';

export type {
IFetchInterceptor,
ISyncResponse,
IAnimationEventInit,
IBrowser,
IBrowserContext,
Expand Down
Loading
Loading