Skip to content

Commit

Permalink
feat: [1502] Allow fetch to be intercepted and modified
Browse files Browse the repository at this point in the history
  • Loading branch information
OlaviSau committed Jan 3, 2025
1 parent 17634e6 commit e95910f
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 1 deletion.
5 changes: 5 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 IAsyncRequestInterceptor from '../../fetch/types/IAsyncRequestInterceptor.js';

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

intercept: {
asyncFetch: IAsyncRequestInterceptor;
};
};

/**
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 IAsyncRequestInterceptor from '../../fetch/types/IAsyncRequestInterceptor.js';

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

intercept: {
asyncFetch: IAsyncRequestInterceptor;
};
};

/**
Expand Down
10 changes: 10 additions & 0 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 IAsyncRequestInterceptor from './types/IAsyncRequestInterceptor.js';

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

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 requestInterceptor?: IAsyncRequestInterceptor;
private request: Request;
private redirectCount = 0;
private disableCache: boolean;
Expand Down Expand Up @@ -99,6 +101,8 @@ export default class Fetch {
options.disableSameOriginPolicy ??
this.#browserFrame.page.context.browser.settings.fetch.disableSameOriginPolicy ??
false;
this.requestInterceptor =
this.#browserFrame.page.context.browser.settings.fetch.intercept.asyncFetch;
}

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

if (this.request.signal.aborted) {
Expand Down
16 changes: 16 additions & 0 deletions packages/happy-dom/src/fetch/types/IAsyncRequestInterceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Request from '../Request.js';
import BrowserWindow from '../../window/BrowserWindow.js';
import Response from '../Response.js';

export default interface IAsyncRequestInterceptor {
/**
* Hook dispatched before sending out async fetches.
* It can be used for modifying the request, providing a response without making a request or for logging.
*
* @param request The request about to be sent out.
* @param window The window from where the request originates.
*
* @returns Promise that can resolve to a response to be used instead of sending out the response.
*/
beforeSend?: (request: Request, window: BrowserWindow) => Promise<Response | void>;
}
2 changes: 2 additions & 0 deletions packages/happy-dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ 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 IAsyncRequestInterceptor from './fetch/types/IAsyncRequestInterceptor.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 +207,7 @@ import type ITouchEventInit from './event/events/ITouchEventInit.js';
import type IWheelEventInit from './event/events/IWheelEventInit.js';

export type {
IAsyncRequestInterceptor,
IAnimationEventInit,
IBrowser,
IBrowserContext,
Expand Down
111 changes: 110 additions & 1 deletion packages/happy-dom/test/fetch/Fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { afterEach, describe, it, expect, vi } from 'vitest';
import FetchHTTPSCertificate from '../../src/fetch/certificate/FetchHTTPSCertificate.js';
import * as PropertySymbol from '../../src/PropertySymbol.js';
import Event from '../../src/event/Event.js';
import Fetch from '../../lib/fetch/Fetch';

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

Expand Down Expand Up @@ -1343,7 +1344,7 @@ describe('Fetch', () => {
});
});

it("Does'nt allow requests to HTTP from HTTPS (mixed content).", async () => {
it("Doesn't allow requests to HTTP from HTTPS (mixed content).", async () => {
const originURL = 'https://localhost:8080/';
const window = new Window({ url: originURL });
const url = 'http://localhost:8080/some/path';
Expand All @@ -1363,6 +1364,114 @@ describe('Fetch', () => {
);
});

it('Uses intercepted response when beforeSend returns a Response', async () => {
const originURL = 'https://localhost:8080/';
const responseText = 'some text';
const window = new Window({
url: originURL,
settings: {
fetch: {
intercept: {
asyncFetch: {
async beforeSend(_request, window) {
return new window.Response('intercepted text');
}
}
}
}
}
});
const url = 'https://localhost:8080/some/path';

mockModule('https', {
request: () => {
return {
end: () => {},
on: (event: string, callback: (response: HTTP.IncomingMessage) => void) => {
if (event === 'response') {
async function* generate(): AsyncGenerator<string> {
yield responseText;
}

const response = <HTTP.IncomingMessage>Stream.Readable.from(generate());

response.statusCode = 200;
response.statusMessage = 'OK';
response.headers = {};
response.rawHeaders = [
'content-type',
'text/html',
'content-length',
String(responseText.length)
];

callback(response);
}
},
setTimeout: () => {}
};
}
});

const response = await window.fetch(url);

expect(await response.text()).toBe('intercepted text');
});

it('Makes a normal request when before does not return a Response', async () => {
const originURL = 'https://localhost:8080/';
const responseText = 'some text';
const window = new Window({
url: originURL,
settings: {
fetch: {
intercept: {
asyncFetch: {
async beforeSend() {
return undefined;
}
}
}
}
}
});
const url = 'https://localhost:8080/some/path';

mockModule('https', {
request: () => {
return {
end: () => {},
on: (event: string, callback: (response: HTTP.IncomingMessage) => void) => {
if (event === 'response') {
async function* generate(): AsyncGenerator<string> {
yield responseText;
}

const response = <HTTP.IncomingMessage>Stream.Readable.from(generate());

response.statusCode = 200;
response.statusMessage = 'OK';
response.headers = {};
response.rawHeaders = [
'content-type',
'text/html',
'content-length',
String(responseText.length)
];

callback(response);
}
},
setTimeout: () => {}
};
}
});

const response = await window.fetch(url);

expect(await response.text()).toBe('some text');
});

it('Forwards "cookie", "authorization" or "www-authenticate" if request credentials are set to "same-origin" and the request goes to the same origin as the document.', async () => {
const originURL = 'https://localhost:8080';
const window = new Window({ url: originURL });
Expand Down

0 comments on commit e95910f

Please sign in to comment.