Skip to content

Commit 8d1cf8f

Browse files
authored
feat: [#1688] Adds support for virtual servers (#1696)
1 parent 8eea167 commit 8d1cf8f

15 files changed

+999
-19
lines changed

packages/happy-dom/src/browser/DefaultBrowserSettings.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export default <IBrowserSettings>{
1919
preventTimerLoops: false
2020
},
2121
fetch: {
22-
disableSameOriginPolicy: false
22+
disableSameOriginPolicy: false,
23+
interceptor: null,
24+
virtualServers: null
2325
},
2426
navigation: {
2527
disableMainFrameNavigation: false,

packages/happy-dom/src/browser/types/IBrowserSettings.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js';
22
import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js';
33
import IFetchInterceptor from '../../fetch/types/IFetchInterceptor.js';
4+
import IVirtualServer from '../../fetch/types/IVirtualServer.js';
45

56
/**
67
* Browser settings.
@@ -42,7 +43,15 @@ export default interface IBrowserSettings {
4243
*/
4344
disableSameOriginPolicy: boolean;
4445

45-
interceptor?: IFetchInterceptor;
46+
/**
47+
* Fetch interceptor.
48+
*/
49+
interceptor: IFetchInterceptor | null;
50+
51+
/**
52+
* Virtual servers used for simulating a server that reads from the file system.
53+
*/
54+
virtualServers: IVirtualServer[] | null;
4655
};
4756

4857
/**

packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import BrowserErrorCaptureEnum from '../enums/BrowserErrorCaptureEnum.js';
22
import BrowserNavigationCrossOriginPolicyEnum from '../enums/BrowserNavigationCrossOriginPolicyEnum.js';
33
import IFetchInterceptor from '../../fetch/types/IFetchInterceptor.js';
4+
import IVirtualServer from '../../fetch/types/IVirtualServer.js';
45

56
export default interface IOptionalBrowserSettings {
67
/** Disables JavaScript evaluation. */
@@ -36,7 +37,15 @@ export default interface IOptionalBrowserSettings {
3637
*/
3738
disableSameOriginPolicy?: boolean;
3839

39-
interceptor?: IFetchInterceptor;
40+
/**
41+
* Fetch interceptor.
42+
*/
43+
interceptor?: IFetchInterceptor | null;
44+
45+
/**
46+
* Virtual servers used for simulating a server that reads from the file system.
47+
*/
48+
virtualServers?: IVirtualServer[] | null;
4049
};
4150

4251
/**

packages/happy-dom/src/fetch/Fetch.ts

+91-12
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import HTTP, { IncomingMessage } from 'http';
88
import HTTPS from 'https';
99
import Zlib from 'zlib';
1010
import URL from '../url/URL.js';
11+
import FS from 'fs';
12+
import Path from 'path';
1113
import { Socket } from 'net';
1214
import Stream from 'stream';
1315
import DataURIParser from './data-uri/DataURIParser.js';
@@ -27,6 +29,7 @@ import FetchHTTPSCertificate from './certificate/FetchHTTPSCertificate.js';
2729
import { Buffer } from 'buffer';
2830
import FetchBodyUtility from './utilities/FetchBodyUtility.js';
2931
import IFetchInterceptor from './types/IFetchInterceptor.js';
32+
import VirtualServerUtility from './utilities/VirtualServerUtility.js';
3033

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

@@ -51,7 +54,7 @@ export default class Fetch {
5154
private nodeResponse: IncomingMessage | null = null;
5255
private response: Response | null = null;
5356
private responseHeaders: Headers | null = null;
54-
private interceptor?: IFetchInterceptor;
57+
private interceptor: IFetchInterceptor | null;
5558
private request: Request;
5659
private redirectCount = 0;
5760
private disableCache: boolean;
@@ -111,17 +114,27 @@ export default class Fetch {
111114
*/
112115
public async send(): Promise<Response> {
113116
FetchRequestReferrerUtility.prepareRequest(new URL(this.#window.location.href), this.request);
114-
const beforeRequestResponse = this.interceptor?.beforeAsyncRequest
115-
? await this.interceptor.beforeAsyncRequest({
116-
request: this.request,
117-
window: this.#window
118-
})
119-
: undefined;
120-
if (beforeRequestResponse instanceof Response) {
121-
return beforeRequestResponse;
117+
118+
if (this.interceptor?.beforeAsyncRequest) {
119+
const taskID = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask();
120+
const response = await this.interceptor.beforeAsyncRequest({
121+
request: this.request,
122+
window: this.#window
123+
});
124+
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);
125+
if (response instanceof Response) {
126+
return response;
127+
}
122128
}
129+
123130
FetchRequestValidationUtility.validateSchema(this.request);
124131

132+
const virtualServerResponse = await this.getVirtualServerResponse();
133+
134+
if (virtualServerResponse) {
135+
return virtualServerResponse;
136+
}
137+
125138
if (this.request.signal.aborted) {
126139
throw new this.#window.DOMException(
127140
'The operation was aborted.',
@@ -171,7 +184,7 @@ export default class Fetch {
171184
const compliesWithCrossOriginPolicy = await this.compliesWithCrossOriginPolicy();
172185

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

286+
/**
287+
* Returns virtual server response.
288+
*
289+
* @returns Response.
290+
*/
291+
private async getVirtualServerResponse(): Promise<Response | null> {
292+
const filePath = VirtualServerUtility.getFilepath(this.#window, this.request.url);
293+
294+
if (!filePath) {
295+
return null;
296+
}
297+
298+
if (this.request.method !== 'GET') {
299+
this.#browserFrame?.page?.console.error(
300+
`${this.request.method} ${this.request.url} 404 (Not Found)`
301+
);
302+
return VirtualServerUtility.getNotFoundResponse(this.#window);
303+
}
304+
305+
const taskID = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask();
306+
let buffer: Buffer;
307+
308+
try {
309+
const stat = await FS.promises.stat(filePath);
310+
buffer = await FS.promises.readFile(
311+
stat.isDirectory() ? Path.join(filePath, 'index.html') : filePath
312+
);
313+
} catch (error) {
314+
this.#browserFrame?.page?.console.error(
315+
`${this.request.method} ${this.request.url} 404 (Not Found)`
316+
);
317+
318+
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);
319+
320+
return VirtualServerUtility.getNotFoundResponse(this.#window);
321+
}
322+
323+
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);
324+
325+
const body = new this.#window.ReadableStream({
326+
start(controller) {
327+
setTimeout(() => {
328+
controller.enqueue(buffer);
329+
controller.close();
330+
});
331+
}
332+
});
333+
334+
const response = new this.#window.Response(body);
335+
336+
response[PropertySymbol.buffer] = buffer;
337+
(<string>response.url) = this.request.url;
338+
339+
return response;
340+
}
341+
273342
/**
274343
* Checks if the request complies with the Cross-Origin policy.
275344
*
@@ -410,7 +479,17 @@ export default class Fetch {
410479
})
411480
: undefined;
412481
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);
413-
resolve(interceptedResponse instanceof Response ? interceptedResponse : response);
482+
const returnResponse =
483+
interceptedResponse instanceof Response ? interceptedResponse : response;
484+
485+
// The browser outputs errors to the console when the response is not ok.
486+
if (returnResponse instanceof Response && !returnResponse.ok) {
487+
this.#browserFrame?.page?.console.error(
488+
`${this.request.method} ${this.request.url} ${returnResponse.status} (${returnResponse.statusText})`
489+
);
490+
}
491+
492+
resolve(returnResponse);
414493
};
415494
this.reject = (error: Error): void => {
416495
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);
@@ -517,7 +596,7 @@ export default class Fetch {
517596
*/
518597
private onError(error: Error): void {
519598
this.finalizeRequest();
520-
this.#window.console.error(error);
599+
this.#browserFrame?.page?.console.error(error);
521600
this.reject(
522601
new this.#window.DOMException(
523602
`Failed to execute "fetch()" on "Window" with URL "${this.request.url}": ${error.message}`,

packages/happy-dom/src/fetch/SyncFetch.ts

+60-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import * as PropertySymbol from '../PropertySymbol.js';
33
import IRequestInfo from './types/IRequestInfo.js';
44
import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js';
55
import URL from '../url/URL.js';
6+
import FS from 'fs';
7+
import Path from 'path';
68
import Request from './Request.js';
79
import IBrowserFrame from '../browser/types/IBrowserFrame.js';
810
import BrowserWindow from '../window/BrowserWindow.js';
@@ -21,6 +23,7 @@ import FetchResponseRedirectUtility from './utilities/FetchResponseRedirectUtili
2123
import FetchCORSUtility from './utilities/FetchCORSUtility.js';
2224
import Fetch from './Fetch.js';
2325
import IFetchInterceptor from './types/IFetchInterceptor.js';
26+
import VirtualServerUtility from './utilities/VirtualServerUtility.js';
2427

2528
interface ISyncHTTPResponse {
2629
error: string;
@@ -96,6 +99,7 @@ export default class SyncFetch {
9699
*/
97100
public send(): ISyncResponse {
98101
FetchRequestReferrerUtility.prepareRequest(new URL(this.#window.location.href), this.request);
102+
99103
const beforeRequestResponse = this.interceptor?.beforeSyncRequest
100104
? this.interceptor.beforeSyncRequest({
101105
request: this.request,
@@ -105,8 +109,15 @@ export default class SyncFetch {
105109
if (typeof beforeRequestResponse === 'object') {
106110
return beforeRequestResponse;
107111
}
112+
108113
FetchRequestValidationUtility.validateSchema(this.request);
109114

115+
const virtualServerResponse = this.getVirtualServerResponse();
116+
117+
if (virtualServerResponse) {
118+
return virtualServerResponse;
119+
}
120+
110121
if (this.request.signal.aborted) {
111122
throw new this.#window.DOMException(
112123
'The operation was aborted.',
@@ -256,6 +267,47 @@ export default class SyncFetch {
256267
};
257268
}
258269

270+
/**
271+
* Returns virtual server response.
272+
*
273+
* @returns Response.
274+
*/
275+
private getVirtualServerResponse(): ISyncResponse | null {
276+
const filePath = VirtualServerUtility.getFilepath(this.#window, this.request.url);
277+
278+
if (!filePath) {
279+
return null;
280+
}
281+
282+
if (this.request.method !== 'GET') {
283+
this.#browserFrame?.page?.console.error(
284+
`${this.request.method} ${this.request.url} 404 (Not Found)`
285+
);
286+
return VirtualServerUtility.getNotFoundSyncResponse(this.#window);
287+
}
288+
289+
let buffer: Buffer;
290+
try {
291+
const stat = FS.statSync(filePath);
292+
buffer = FS.readFileSync(stat.isDirectory() ? Path.join(filePath, 'index.html') : filePath);
293+
} catch {
294+
this.#browserFrame?.page?.console.error(
295+
`${this.request.method} ${this.request.url} 404 (Not Found)`
296+
);
297+
return VirtualServerUtility.getNotFoundSyncResponse(this.#window);
298+
}
299+
300+
return {
301+
status: 200,
302+
statusText: '',
303+
ok: true,
304+
url: this.request.url,
305+
redirected: false,
306+
headers: new this.#window.Headers(),
307+
body: buffer
308+
};
309+
}
310+
259311
/**
260312
* Checks if the request complies with the Cross-Origin policy.
261313
*
@@ -443,7 +495,14 @@ export default class SyncFetch {
443495
request: this.request
444496
})
445497
: undefined;
446-
return typeof interceptedResponse === 'object' ? interceptedResponse : redirectedResponse;
498+
const returnResponse =
499+
typeof interceptedResponse === 'object' ? interceptedResponse : redirectedResponse;
500+
if (!returnResponse.ok) {
501+
this.#browserFrame?.page?.console.error(
502+
`${this.request.method} ${this.request.url} ${returnResponse.status} (${returnResponse.statusText})`
503+
);
504+
}
505+
return returnResponse;
447506
}
448507

449508
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Virtual server used for simulating a server that reads from the file system.
3+
*/
4+
export default interface IVirtualServer {
5+
url: string | RegExp;
6+
directory: string;
7+
}

0 commit comments

Comments
 (0)