Skip to content

Commit

Permalink
feat: [#1502] Allow fetch to be intercepted and modified (#1662)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
OlaviSau authored Jan 7, 2025
1 parent 5039b05 commit 4975dc5
Show file tree
Hide file tree
Showing 8 changed files with 649 additions and 8 deletions.
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
37 changes: 32 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;
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 @@ -382,8 +401,16 @@ export default class Fetch {
waitingForBody: !response[PropertySymbol.buffer] && !!response.body
});
}

const interceptedResponse = this.interceptor?.afterAsyncResponse
? await this.interceptor.afterAsyncResponse({
window: this.#window,
response: await response,
request: this.request
})
: undefined;
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);
resolve(response);
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

0 comments on commit 4975dc5

Please sign in to comment.