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: [#1688] Adds support for virtual servers #1696

Merged
merged 6 commits into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
Loading