diff --git a/packages/happy-dom/src/browser/types/IBrowserSettings.ts b/packages/happy-dom/src/browser/types/IBrowserSettings.ts index f7a6f0280..282b1804c 100644 --- a/packages/happy-dom/src/browser/types/IBrowserSettings.ts +++ b/packages/happy-dom/src/browser/types/IBrowserSettings.ts @@ -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. @@ -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; + }; }; /** diff --git a/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts b/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts index bd58fdb8b..2c9f5c208 100644 --- a/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts +++ b/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts @@ -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. */ @@ -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; + }; }; /** diff --git a/packages/happy-dom/src/fetch/Fetch.ts b/packages/happy-dom/src/fetch/Fetch.ts index 3aa304e13..2f4913c12 100644 --- a/packages/happy-dom/src/fetch/Fetch.ts +++ b/packages/happy-dom/src/fetch/Fetch.ts @@ -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'); @@ -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; @@ -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; } /** @@ -108,6 +112,12 @@ export default class Fetch { */ public async send(): Promise { 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) { diff --git a/packages/happy-dom/src/fetch/types/IAsyncRequestInterceptor.ts b/packages/happy-dom/src/fetch/types/IAsyncRequestInterceptor.ts new file mode 100644 index 000000000..5bb3f9b89 --- /dev/null +++ b/packages/happy-dom/src/fetch/types/IAsyncRequestInterceptor.ts @@ -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; +} diff --git a/packages/happy-dom/src/index.ts b/packages/happy-dom/src/index.ts index a0b3fda2c..e852c5a85 100644 --- a/packages/happy-dom/src/index.ts +++ b/packages/happy-dom/src/index.ts @@ -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'; @@ -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, diff --git a/packages/happy-dom/test/fetch/Fetch.test.ts b/packages/happy-dom/test/fetch/Fetch.test.ts index fbd3088a4..fdaaca7bd 100644 --- a/packages/happy-dom/test/fetch/Fetch.test.ts +++ b/packages/happy-dom/test/fetch/Fetch.test.ts @@ -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'); @@ -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'; @@ -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 { + yield responseText; + } + + const response = 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 { + yield responseText; + } + + const response = 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 });