Skip to content

Commit

Permalink
feat: [#1688] Adds support for virtual servers (#1696)
Browse files Browse the repository at this point in the history
  • Loading branch information
capricorn86 authored Jan 20, 2025
1 parent 8eea167 commit 8d1cf8f
Show file tree
Hide file tree
Showing 15 changed files with 999 additions and 19 deletions.
4 changes: 3 additions & 1 deletion packages/happy-dom/src/browser/DefaultBrowserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ export default <IBrowserSettings>{
preventTimerLoops: false
},
fetch: {
disableSameOriginPolicy: false
disableSameOriginPolicy: false,
interceptor: null,
virtualServers: null
},
navigation: {
disableMainFrameNavigation: false,
Expand Down
11 changes: 10 additions & 1 deletion packages/happy-dom/src/browser/types/IBrowserSettings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js';
import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js';
import IFetchInterceptor from '../../fetch/types/IFetchInterceptor.js';
import IVirtualServer from '../../fetch/types/IVirtualServer.js';

/**
* Browser settings.
Expand Down Expand Up @@ -42,7 +43,15 @@ export default interface IBrowserSettings {
*/
disableSameOriginPolicy: boolean;

interceptor?: IFetchInterceptor;
/**
* Fetch interceptor.
*/
interceptor: IFetchInterceptor | null;

/**
* Virtual servers used for simulating a server that reads from the file system.
*/
virtualServers: IVirtualServer[] | null;
};

/**
Expand Down
11 changes: 10 additions & 1 deletion packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js';
import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js';
import IFetchInterceptor from '../../fetch/types/IFetchInterceptor.js';
import IVirtualServer from '../../fetch/types/IVirtualServer.js';

export default interface IOptionalBrowserSettings {
/** Disables JavaScript evaluation. */
Expand Down Expand Up @@ -36,7 +37,15 @@ export default interface IOptionalBrowserSettings {
*/
disableSameOriginPolicy?: boolean;

interceptor?: IFetchInterceptor;
/**
* Fetch interceptor.
*/
interceptor?: IFetchInterceptor | null;

/**
* Virtual servers used for simulating a server that reads from the file system.
*/
virtualServers?: IVirtualServer[] | null;
};

/**
Expand Down
103 changes: 91 additions & 12 deletions packages/happy-dom/src/fetch/Fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import HTTP, { IncomingMessage } from 'http';
import HTTPS from 'https';
import Zlib from 'zlib';
import URL from '../url/URL.js';
import FS from 'fs';
import Path from 'path';
import { Socket } from 'net';
import Stream from 'stream';
import DataURIParser from './data-uri/DataURIParser.js';
Expand All @@ -27,6 +29,7 @@ import FetchHTTPSCertificate from './certificate/FetchHTTPSCertificate.js';
import { Buffer } from 'buffer';
import FetchBodyUtility from './utilities/FetchBodyUtility.js';
import IFetchInterceptor from './types/IFetchInterceptor.js';
import VirtualServerUtility from './utilities/VirtualServerUtility.js';

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

Expand All @@ -51,7 +54,7 @@ export default class Fetch {
private nodeResponse: IncomingMessage | null = null;
private response: Response | null = null;
private responseHeaders: Headers | null = null;
private interceptor?: IFetchInterceptor;
private interceptor: IFetchInterceptor | null;
private request: Request;
private redirectCount = 0;
private disableCache: boolean;
Expand Down Expand Up @@ -111,17 +114,27 @@ 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;

if (this.interceptor?.beforeAsyncRequest) {
const taskID = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask();
const response = await this.interceptor.beforeAsyncRequest({
request: this.request,
window: this.#window
});
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);
if (response instanceof Response) {
return response;
}
}

FetchRequestValidationUtility.validateSchema(this.request);

const virtualServerResponse = await this.getVirtualServerResponse();

if (virtualServerResponse) {
return virtualServerResponse;
}

if (this.request.signal.aborted) {
throw new this.#window.DOMException(
'The operation was aborted.',
Expand Down Expand Up @@ -171,7 +184,7 @@ export default class Fetch {
const compliesWithCrossOriginPolicy = await this.compliesWithCrossOriginPolicy();

if (!compliesWithCrossOriginPolicy) {
this.#window.console.warn(
this.#browserFrame?.page?.console.warn(
`Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at "${this.request.url}".`
);
throw new this.#window.DOMException(
Expand Down Expand Up @@ -270,6 +283,62 @@ export default class Fetch {
return response;
}

/**
* Returns virtual server response.
*
* @returns Response.
*/
private async getVirtualServerResponse(): Promise<Response | null> {
const filePath = VirtualServerUtility.getFilepath(this.#window, this.request.url);

if (!filePath) {
return null;
}

if (this.request.method !== 'GET') {
this.#browserFrame?.page?.console.error(
`${this.request.method} ${this.request.url} 404 (Not Found)`
);
return VirtualServerUtility.getNotFoundResponse(this.#window);
}

const taskID = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask();
let buffer: Buffer;

try {
const stat = await FS.promises.stat(filePath);
buffer = await FS.promises.readFile(
stat.isDirectory() ? Path.join(filePath, 'index.html') : filePath
);
} catch (error) {
this.#browserFrame?.page?.console.error(
`${this.request.method} ${this.request.url} 404 (Not Found)`
);

this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);

return VirtualServerUtility.getNotFoundResponse(this.#window);
}

this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);

const body = new this.#window.ReadableStream({
start(controller) {
setTimeout(() => {
controller.enqueue(buffer);
controller.close();
});
}
});

const response = new this.#window.Response(body);

response[PropertySymbol.buffer] = buffer;
(<string>response.url) = this.request.url;

return response;
}

/**
* Checks if the request complies with the Cross-Origin policy.
*
Expand Down Expand Up @@ -410,7 +479,17 @@ export default class Fetch {
})
: undefined;
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);
resolve(interceptedResponse instanceof Response ? interceptedResponse : response);
const returnResponse =
interceptedResponse instanceof Response ? interceptedResponse : response;

// The browser outputs errors to the console when the response is not ok.
if (returnResponse instanceof Response && !returnResponse.ok) {
this.#browserFrame?.page?.console.error(
`${this.request.method} ${this.request.url} ${returnResponse.status} (${returnResponse.statusText})`
);
}

resolve(returnResponse);
};
this.reject = (error: Error): void => {
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);
Expand Down Expand Up @@ -517,7 +596,7 @@ export default class Fetch {
*/
private onError(error: Error): void {
this.finalizeRequest();
this.#window.console.error(error);
this.#browserFrame?.page?.console.error(error);
this.reject(
new this.#window.DOMException(
`Failed to execute "fetch()" on "Window" with URL "${this.request.url}": ${error.message}`,
Expand Down
61 changes: 60 additions & 1 deletion packages/happy-dom/src/fetch/SyncFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import * as PropertySymbol from '../PropertySymbol.js';
import IRequestInfo from './types/IRequestInfo.js';
import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js';
import URL from '../url/URL.js';
import FS from 'fs';
import Path from 'path';
import Request from './Request.js';
import IBrowserFrame from '../browser/types/IBrowserFrame.js';
import BrowserWindow from '../window/BrowserWindow.js';
Expand All @@ -21,6 +23,7 @@ import FetchResponseRedirectUtility from './utilities/FetchResponseRedirectUtili
import FetchCORSUtility from './utilities/FetchCORSUtility.js';
import Fetch from './Fetch.js';
import IFetchInterceptor from './types/IFetchInterceptor.js';
import VirtualServerUtility from './utilities/VirtualServerUtility.js';

interface ISyncHTTPResponse {
error: string;
Expand Down Expand Up @@ -96,6 +99,7 @@ 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,
Expand All @@ -105,8 +109,15 @@ export default class SyncFetch {
if (typeof beforeRequestResponse === 'object') {
return beforeRequestResponse;
}

FetchRequestValidationUtility.validateSchema(this.request);

const virtualServerResponse = this.getVirtualServerResponse();

if (virtualServerResponse) {
return virtualServerResponse;
}

if (this.request.signal.aborted) {
throw new this.#window.DOMException(
'The operation was aborted.',
Expand Down Expand Up @@ -256,6 +267,47 @@ export default class SyncFetch {
};
}

/**
* Returns virtual server response.
*
* @returns Response.
*/
private getVirtualServerResponse(): ISyncResponse | null {
const filePath = VirtualServerUtility.getFilepath(this.#window, this.request.url);

if (!filePath) {
return null;
}

if (this.request.method !== 'GET') {
this.#browserFrame?.page?.console.error(
`${this.request.method} ${this.request.url} 404 (Not Found)`
);
return VirtualServerUtility.getNotFoundSyncResponse(this.#window);
}

let buffer: Buffer;
try {
const stat = FS.statSync(filePath);
buffer = FS.readFileSync(stat.isDirectory() ? Path.join(filePath, 'index.html') : filePath);
} catch {
this.#browserFrame?.page?.console.error(
`${this.request.method} ${this.request.url} 404 (Not Found)`
);
return VirtualServerUtility.getNotFoundSyncResponse(this.#window);
}

return {
status: 200,
statusText: '',
ok: true,
url: this.request.url,
redirected: false,
headers: new this.#window.Headers(),
body: buffer
};
}

/**
* Checks if the request complies with the Cross-Origin policy.
*
Expand Down Expand Up @@ -443,7 +495,14 @@ export default class SyncFetch {
request: this.request
})
: undefined;
return typeof interceptedResponse === 'object' ? interceptedResponse : redirectedResponse;
const returnResponse =
typeof interceptedResponse === 'object' ? interceptedResponse : redirectedResponse;
if (!returnResponse.ok) {
this.#browserFrame?.page?.console.error(
`${this.request.method} ${this.request.url} ${returnResponse.status} (${returnResponse.statusText})`
);
}
return returnResponse;
}

/**
Expand Down
7 changes: 7 additions & 0 deletions packages/happy-dom/src/fetch/types/IVirtualServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Virtual server used for simulating a server that reads from the file system.
*/
export default interface IVirtualServer {
url: string | RegExp;
directory: string;
}
Loading

0 comments on commit 8d1cf8f

Please sign in to comment.