@happy-dom/server-renderer 19.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/bin/lib/happy-dom-sr.d.ts +3 -0
- package/bin/lib/happy-dom-sr.d.ts.map +1 -0
- package/bin/lib/happy-dom-sr.js +25 -0
- package/bin/lib/happy-dom-sr.js.map +1 -0
- package/bin/src/happy-dom-sr.ts +30 -0
- package/lib/ServerRenderer.d.ts +36 -0
- package/lib/ServerRenderer.d.ts.map +1 -0
- package/lib/ServerRenderer.js +270 -0
- package/lib/ServerRenderer.js.map +1 -0
- package/lib/ServerRendererBrowser.d.ts +26 -0
- package/lib/ServerRendererBrowser.d.ts.map +1 -0
- package/lib/ServerRendererBrowser.js +244 -0
- package/lib/ServerRendererBrowser.js.map +1 -0
- package/lib/ServerRendererServer.d.ts +22 -0
- package/lib/ServerRendererServer.d.ts.map +1 -0
- package/lib/ServerRendererServer.js +263 -0
- package/lib/ServerRendererServer.js.map +1 -0
- package/lib/ServerRendererWorker.d.ts +10 -0
- package/lib/ServerRendererWorker.d.ts.map +1 -0
- package/lib/ServerRendererWorker.js +25 -0
- package/lib/ServerRendererWorker.js.map +1 -0
- package/lib/config/DefaultServerRendererConfiguration.d.ts +4 -0
- package/lib/config/DefaultServerRendererConfiguration.d.ts.map +1 -0
- package/lib/config/DefaultServerRendererConfiguration.js +40 -0
- package/lib/config/DefaultServerRendererConfiguration.js.map +1 -0
- package/lib/enums/ServerRendererLogLevelEnum.d.ts +9 -0
- package/lib/enums/ServerRendererLogLevelEnum.d.ts.map +1 -0
- package/lib/enums/ServerRendererLogLevelEnum.js +10 -0
- package/lib/enums/ServerRendererLogLevelEnum.js.map +1 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +5 -0
- package/lib/index.js.map +1 -0
- package/lib/types/IOptionalServerRendererConfiguration.d.ts +108 -0
- package/lib/types/IOptionalServerRendererConfiguration.d.ts.map +1 -0
- package/lib/types/IOptionalServerRendererConfiguration.js +2 -0
- package/lib/types/IOptionalServerRendererConfiguration.js.map +1 -0
- package/lib/types/IServerRendererConfiguration.d.ts +108 -0
- package/lib/types/IServerRendererConfiguration.d.ts.map +1 -0
- package/lib/types/IServerRendererConfiguration.js +2 -0
- package/lib/types/IServerRendererConfiguration.js.map +1 -0
- package/lib/types/IServerRendererItem.d.ts +8 -0
- package/lib/types/IServerRendererItem.d.ts.map +1 -0
- package/lib/types/IServerRendererItem.js +2 -0
- package/lib/types/IServerRendererItem.js.map +1 -0
- package/lib/types/IServerRendererResult.d.ts +14 -0
- package/lib/types/IServerRendererResult.d.ts.map +1 -0
- package/lib/types/IServerRendererResult.js +2 -0
- package/lib/types/IServerRendererResult.js.map +1 -0
- package/lib/utilities/BrowserWindowPolyfill.d.ts +13 -0
- package/lib/utilities/BrowserWindowPolyfill.d.ts.map +1 -0
- package/lib/utilities/BrowserWindowPolyfill.js +92 -0
- package/lib/utilities/BrowserWindowPolyfill.js.map +1 -0
- package/lib/utilities/HelpPrinter.d.ts +10 -0
- package/lib/utilities/HelpPrinter.d.ts.map +1 -0
- package/lib/utilities/HelpPrinter.js +32 -0
- package/lib/utilities/HelpPrinter.js.map +1 -0
- package/lib/utilities/HelpPrinterRows.d.ts +3 -0
- package/lib/utilities/HelpPrinterRows.d.ts.map +1 -0
- package/lib/utilities/HelpPrinterRows.js +261 -0
- package/lib/utilities/HelpPrinterRows.js.map +1 -0
- package/lib/utilities/PackageVersion.d.ts +10 -0
- package/lib/utilities/PackageVersion.d.ts.map +1 -0
- package/lib/utilities/PackageVersion.js +13 -0
- package/lib/utilities/PackageVersion.js.map +1 -0
- package/lib/utilities/ProcessArgumentsParser.d.ts +20 -0
- package/lib/utilities/ProcessArgumentsParser.d.ts.map +1 -0
- package/lib/utilities/ProcessArgumentsParser.js +319 -0
- package/lib/utilities/ProcessArgumentsParser.js.map +1 -0
- package/lib/utilities/ServerRendererConfigurationFactory.d.ts +23 -0
- package/lib/utilities/ServerRendererConfigurationFactory.d.ts.map +1 -0
- package/lib/utilities/ServerRendererConfigurationFactory.js +82 -0
- package/lib/utilities/ServerRendererConfigurationFactory.js.map +1 -0
- package/package.json +61 -0
- package/src/ServerRenderer.ts +345 -0
- package/src/ServerRendererBrowser.ts +286 -0
- package/src/ServerRendererServer.ts +337 -0
- package/src/ServerRendererWorker.ts +29 -0
- package/src/config/DefaultServerRendererConfiguration.ts +41 -0
- package/src/enums/ServerRendererLogLevelEnum.ts +9 -0
- package/src/index.ts +17 -0
- package/src/types/IOptionalServerRendererConfiguration.ts +108 -0
- package/src/types/IServerRendererConfiguration.ts +108 -0
- package/src/types/IServerRendererItem.ts +5 -0
- package/src/types/IServerRendererResult.ts +11 -0
- package/src/utilities/BrowserWindowPolyfill.ts +93 -0
- package/src/utilities/HelpPrinter.ts +36 -0
- package/src/utilities/HelpPrinterRows.ts +260 -0
- package/src/utilities/PackageVersion.ts +12 -0
- package/src/utilities/ProcessArgumentsParser.ts +294 -0
- package/src/utilities/ServerRendererConfigurationFactory.ts +91 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import Browser from 'happy-dom/lib/browser/Browser.js';
|
|
2
|
+
import HTMLSerializer from 'happy-dom/lib/html-serializer/HTMLSerializer.js';
|
|
3
|
+
import IServerRendererConfiguration from './types/IServerRendererConfiguration.js';
|
|
4
|
+
import FS from 'fs';
|
|
5
|
+
import Path from 'path';
|
|
6
|
+
import IServerRendererItem from './types/IServerRendererItem.js';
|
|
7
|
+
import IServerRendererResult from './types/IServerRendererResult.js';
|
|
8
|
+
import { ErrorEvent } from 'happy-dom';
|
|
9
|
+
import BrowserWindowPolyfill from './utilities/BrowserWindowPolyfill.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Server renderer browser.
|
|
13
|
+
*/
|
|
14
|
+
export default class ServerRendererBrowser {
|
|
15
|
+
#configuration: IServerRendererConfiguration;
|
|
16
|
+
#browser: Browser;
|
|
17
|
+
#isCacheLoaded: boolean = false;
|
|
18
|
+
#createdDirectories: Set<string> = new Set();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Constructor.
|
|
22
|
+
*
|
|
23
|
+
* @param configuration Configuration.
|
|
24
|
+
*/
|
|
25
|
+
constructor(configuration: IServerRendererConfiguration) {
|
|
26
|
+
this.#configuration = configuration;
|
|
27
|
+
const settings =
|
|
28
|
+
configuration.debug && configuration.browser.debug?.traceWaitUntilComplete === -1
|
|
29
|
+
? {
|
|
30
|
+
...configuration.browser,
|
|
31
|
+
debug: {
|
|
32
|
+
...configuration.browser.debug,
|
|
33
|
+
traceWaitUntilComplete: configuration.render.timeout
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
: configuration.browser;
|
|
37
|
+
this.#browser = new Browser({ settings });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Renders URLs.
|
|
42
|
+
*
|
|
43
|
+
* @param items Items.
|
|
44
|
+
*/
|
|
45
|
+
public async render(items: IServerRendererItem[]): Promise<IServerRendererResult[]> {
|
|
46
|
+
const browser = this.#browser;
|
|
47
|
+
|
|
48
|
+
await this.#loadCache(browser);
|
|
49
|
+
|
|
50
|
+
let results: IServerRendererResult[];
|
|
51
|
+
|
|
52
|
+
if (items.length > this.#configuration.render.maxConcurrency) {
|
|
53
|
+
results = [];
|
|
54
|
+
while (items.length) {
|
|
55
|
+
const chunk = items.splice(0, this.#configuration.render.maxConcurrency);
|
|
56
|
+
const promises = [];
|
|
57
|
+
for (const url of chunk) {
|
|
58
|
+
promises.push(
|
|
59
|
+
this.#renderURL(browser, url).then((result) => {
|
|
60
|
+
results.push(result);
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
await Promise.all(promises);
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
const promises = [];
|
|
68
|
+
for (const url of items) {
|
|
69
|
+
promises.push(this.#renderURL(browser, url));
|
|
70
|
+
}
|
|
71
|
+
results = await Promise.all(promises);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await this.#saveCache(browser);
|
|
75
|
+
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Closes the browser.
|
|
81
|
+
*/
|
|
82
|
+
public async close(): Promise<void> {
|
|
83
|
+
await this.#browser.close();
|
|
84
|
+
this.#browser = null!;
|
|
85
|
+
this.#isCacheLoaded = false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Renders a page.
|
|
90
|
+
*
|
|
91
|
+
* @param browser Browser.
|
|
92
|
+
* @param item Item.
|
|
93
|
+
*/
|
|
94
|
+
async #renderURL(browser: Browser, item: IServerRendererItem): Promise<IServerRendererResult> {
|
|
95
|
+
const responseCache = browser.defaultContext.responseCache;
|
|
96
|
+
const preflightResponseCache = browser.defaultContext.preflightResponseCache;
|
|
97
|
+
const configuration = this.#configuration;
|
|
98
|
+
const context =
|
|
99
|
+
configuration.render.incognitoContext || configuration.cache.disable
|
|
100
|
+
? browser.newIncognitoContext()
|
|
101
|
+
: browser.defaultContext;
|
|
102
|
+
const page = context.newPage();
|
|
103
|
+
|
|
104
|
+
if (configuration.render.incognitoContext && !configuration.cache.disable) {
|
|
105
|
+
// @ts-ignore
|
|
106
|
+
context.responseCache = responseCache;
|
|
107
|
+
// @ts-ignore
|
|
108
|
+
context.preflightResponseCache = preflightResponseCache;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pageErrors: string[] = [];
|
|
112
|
+
const response = (await page.goto(item.url, {
|
|
113
|
+
timeout: configuration.render.timeout,
|
|
114
|
+
headers: item.headers,
|
|
115
|
+
beforeContentCallback(window) {
|
|
116
|
+
window.addEventListener('error', (event) => {
|
|
117
|
+
if ((<ErrorEvent>event).error) {
|
|
118
|
+
pageErrors.push((<ErrorEvent>event).error!.stack!);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
if (!configuration.render.disablePolyfills) {
|
|
122
|
+
BrowserWindowPolyfill.applyPolyfills(window);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}))!;
|
|
126
|
+
|
|
127
|
+
const headers: { [key: string]: string } = {};
|
|
128
|
+
|
|
129
|
+
for (const [key, value] of response.headers) {
|
|
130
|
+
if (key !== 'content-encoding') {
|
|
131
|
+
headers[key] = value;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
const pageConsole = page.virtualConsolePrinter.readAsString();
|
|
137
|
+
await page.close();
|
|
138
|
+
return {
|
|
139
|
+
url: item.url,
|
|
140
|
+
content: null,
|
|
141
|
+
status: response.status,
|
|
142
|
+
statusText: response.statusText,
|
|
143
|
+
headers,
|
|
144
|
+
outputFile: item.outputFile ?? null,
|
|
145
|
+
error: `Failed to render page ${item.url} (${response.status} ${response.statusText})`,
|
|
146
|
+
pageConsole,
|
|
147
|
+
pageErrors
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let timeoutError: string | null = null;
|
|
152
|
+
const timeout =
|
|
153
|
+
this.#browser.settings.debug.traceWaitUntilComplete === -1
|
|
154
|
+
? setTimeout(() => {
|
|
155
|
+
timeoutError = `The page was not rendered within the defined time of ${configuration.render.timeout}ms and the operation was aborted. You can increase this value with the "render.timeout" setting.\n\nThe page may contain scripts with timer loops that prevent it from completing. You can debug open handles by setting "debug" to true, or prevent timer loops by setting "browser.timer.preventTimerLoops" to true. Read more about this in the documentation.`;
|
|
156
|
+
page.abort();
|
|
157
|
+
}, configuration.render.timeout)
|
|
158
|
+
: null;
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await page.waitUntilComplete();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const pageConsole = page.virtualConsolePrinter.readAsString();
|
|
164
|
+
await page.close();
|
|
165
|
+
return {
|
|
166
|
+
url: item.url,
|
|
167
|
+
content: null,
|
|
168
|
+
status: response.status,
|
|
169
|
+
statusText: response.statusText,
|
|
170
|
+
headers,
|
|
171
|
+
outputFile: item.outputFile ?? null,
|
|
172
|
+
error: (<Error>error).stack!,
|
|
173
|
+
pageConsole,
|
|
174
|
+
pageErrors
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Wait for errors to be printed
|
|
179
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
180
|
+
|
|
181
|
+
if (timeout) {
|
|
182
|
+
clearTimeout(timeout);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const pageConsole = page.virtualConsolePrinter.readAsString();
|
|
186
|
+
|
|
187
|
+
if (timeoutError) {
|
|
188
|
+
page.close();
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
url: item.url,
|
|
192
|
+
content: null,
|
|
193
|
+
status: response.status,
|
|
194
|
+
statusText: response.statusText,
|
|
195
|
+
headers,
|
|
196
|
+
outputFile: null,
|
|
197
|
+
error: timeoutError,
|
|
198
|
+
pageConsole,
|
|
199
|
+
pageErrors
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const serializer = new HTMLSerializer({
|
|
204
|
+
allShadowRoots: configuration.render.allShadowRoots,
|
|
205
|
+
serializableShadowRoots: configuration.render.serializableShadowRoots,
|
|
206
|
+
excludeShadowRootTags: configuration.render.excludeShadowRootTags
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const result = serializer.serializeToString(page.mainFrame.document);
|
|
210
|
+
|
|
211
|
+
await page.close();
|
|
212
|
+
|
|
213
|
+
if (!item.outputFile) {
|
|
214
|
+
return {
|
|
215
|
+
url: item.url,
|
|
216
|
+
content: result,
|
|
217
|
+
status: response.status,
|
|
218
|
+
statusText: response.statusText,
|
|
219
|
+
headers,
|
|
220
|
+
outputFile: null,
|
|
221
|
+
error: null,
|
|
222
|
+
pageConsole,
|
|
223
|
+
pageErrors
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const directory = Path.dirname(item.outputFile);
|
|
228
|
+
|
|
229
|
+
if (!this.#createdDirectories.has(directory)) {
|
|
230
|
+
this.#createdDirectories.add(directory);
|
|
231
|
+
try {
|
|
232
|
+
await FS.promises.mkdir(directory, {
|
|
233
|
+
recursive: true
|
|
234
|
+
});
|
|
235
|
+
} catch {
|
|
236
|
+
// Ignore
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await FS.promises.writeFile(item.outputFile, result);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
url: item.url,
|
|
244
|
+
content: null,
|
|
245
|
+
status: response.status,
|
|
246
|
+
statusText: response.statusText,
|
|
247
|
+
headers,
|
|
248
|
+
outputFile: item.outputFile,
|
|
249
|
+
error: null,
|
|
250
|
+
pageConsole,
|
|
251
|
+
pageErrors
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Loads cache from disk.
|
|
257
|
+
*
|
|
258
|
+
* @param browser Browser.
|
|
259
|
+
*/
|
|
260
|
+
async #loadCache(browser: Browser): Promise<void> {
|
|
261
|
+
if (
|
|
262
|
+
this.#configuration.cache.disable ||
|
|
263
|
+
!this.#configuration.cache.directory ||
|
|
264
|
+
this.#isCacheLoaded
|
|
265
|
+
) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
this.#isCacheLoaded = true;
|
|
270
|
+
|
|
271
|
+
await browser.defaultContext.responseCache.fileSystem.load(this.#configuration.cache.directory);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Saves cache to disk.
|
|
276
|
+
*
|
|
277
|
+
* @param browser Browser.
|
|
278
|
+
*/
|
|
279
|
+
async #saveCache(browser: Browser): Promise<void> {
|
|
280
|
+
if (this.#configuration.cache.disable || !this.#configuration.cache.directory) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
await browser.defaultContext.responseCache.fileSystem.save(this.#configuration.cache.directory);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import Http2 from 'http2';
|
|
2
|
+
import IOptionalServerRendererConfiguration from './types/IOptionalServerRendererConfiguration.js';
|
|
3
|
+
import IServerRendererConfiguration from './types/IServerRendererConfiguration.js';
|
|
4
|
+
import ServerRendererConfigurationFactory from './utilities/ServerRendererConfigurationFactory.js';
|
|
5
|
+
import ServerRenderer from './ServerRenderer.js';
|
|
6
|
+
import FetchHTTPSCertificate from 'happy-dom/lib/fetch/certificate/FetchHTTPSCertificate.js';
|
|
7
|
+
import ZLib from 'node:zlib';
|
|
8
|
+
import Stream from 'node:stream/promises';
|
|
9
|
+
import OS from 'node:os';
|
|
10
|
+
import IServerRendererResult from './types/IServerRendererResult.js';
|
|
11
|
+
// eslint-disable-next-line import/no-named-as-default
|
|
12
|
+
import Chalk from 'chalk';
|
|
13
|
+
import ServerRendererLogLevelEnum from './enums/ServerRendererLogLevelEnum.js';
|
|
14
|
+
import PackageVersion from './utilities/PackageVersion.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Server renderer proxy HTTP2 server.
|
|
18
|
+
*/
|
|
19
|
+
export default class ServerRendererServer {
|
|
20
|
+
#configuration: IServerRendererConfiguration;
|
|
21
|
+
#serverRenderer: ServerRenderer;
|
|
22
|
+
#server: Http2.Http2Server | null = null;
|
|
23
|
+
#cache: Map<
|
|
24
|
+
string,
|
|
25
|
+
{
|
|
26
|
+
timestamp: number;
|
|
27
|
+
result: IServerRendererResult;
|
|
28
|
+
}
|
|
29
|
+
> = new Map();
|
|
30
|
+
#cacheQueue: Map<
|
|
31
|
+
string,
|
|
32
|
+
Array<{ resolve: (result: IServerRendererResult) => void; reject: (error: Error) => void }>
|
|
33
|
+
> = new Map();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Constructor.
|
|
37
|
+
*
|
|
38
|
+
* @param configuration Configuration.
|
|
39
|
+
*/
|
|
40
|
+
constructor(configuration?: IOptionalServerRendererConfiguration) {
|
|
41
|
+
this.#configuration = ServerRendererConfigurationFactory.createConfiguration(configuration);
|
|
42
|
+
this.#serverRenderer = new ServerRenderer(this.#configuration);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Starts the server.
|
|
47
|
+
*/
|
|
48
|
+
public async start(): Promise<void> {
|
|
49
|
+
let url: URL;
|
|
50
|
+
try {
|
|
51
|
+
url = new URL(this.#configuration.server.serverURL);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new Error('Failed to start server. The setting "server.serverURL" is not a valid URL.');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!this.#configuration.server.targetOrigin) {
|
|
57
|
+
throw new Error('Failed to start server. The setting "server.targetOrigin" is not set.');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let targetOrigin: URL;
|
|
61
|
+
try {
|
|
62
|
+
targetOrigin = new URL(this.#configuration.server.targetOrigin);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
'Failed to start server. The setting "server.targetOrigin" is not a valid URL.'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
switch (url.protocol) {
|
|
70
|
+
case 'http:':
|
|
71
|
+
this.#server = Http2.createServer(
|
|
72
|
+
(request: Http2.Http2ServerRequest, response: Http2.Http2ServerResponse) =>
|
|
73
|
+
this.#onIncomingRequest(request, response)
|
|
74
|
+
);
|
|
75
|
+
break;
|
|
76
|
+
case 'https:':
|
|
77
|
+
this.#server = Http2.createSecureServer(
|
|
78
|
+
{
|
|
79
|
+
key: FetchHTTPSCertificate.key,
|
|
80
|
+
cert: FetchHTTPSCertificate.cert
|
|
81
|
+
},
|
|
82
|
+
(request: Http2.Http2ServerRequest, response: Http2.Http2ServerResponse) =>
|
|
83
|
+
this.#onIncomingRequest(request, response)
|
|
84
|
+
);
|
|
85
|
+
break;
|
|
86
|
+
default:
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Unsupported protocol "${url.protocol}". Only "http:" and "https:" are supported.`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.#server.listen(url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80);
|
|
93
|
+
|
|
94
|
+
// eslint-disable-next-line no-console
|
|
95
|
+
console.log(Chalk.green(`\nHappy DOM Proxy Server ${await PackageVersion.getVersion()}\n`));
|
|
96
|
+
|
|
97
|
+
// eslint-disable-next-line no-console
|
|
98
|
+
console.log(
|
|
99
|
+
` ${Chalk.green('➜')} ${Chalk.bold('Local:')} ${Chalk.cyan(
|
|
100
|
+
`${url.protocol}//localhost:${url.port}/`
|
|
101
|
+
)}\n ${Chalk.green('➜')} ${Chalk.bold('Network:')} ${Chalk.cyan(
|
|
102
|
+
`${url.protocol}//${this.#getNetworkIP()}:${url.port}/`
|
|
103
|
+
)}\n ${Chalk.green('➜')} ${Chalk.bold('Target:')} ${Chalk.cyan(
|
|
104
|
+
`${targetOrigin.protocol}//${targetOrigin.host}/`
|
|
105
|
+
)}\n\n ${Chalk.green('➜')} ${Chalk.bold('URL:')} ${Chalk.cyan(
|
|
106
|
+
`${url.protocol}//localhost:${url.port}${url.pathname}${url.search}${url.hash}`
|
|
107
|
+
)}\n`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Stops the server.
|
|
113
|
+
*/
|
|
114
|
+
public async stop(): Promise<void> {
|
|
115
|
+
if (this.#server) {
|
|
116
|
+
this.#server.close();
|
|
117
|
+
}
|
|
118
|
+
if (this.#serverRenderer) {
|
|
119
|
+
await this.#serverRenderer.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Triggered on incoming request.
|
|
125
|
+
*
|
|
126
|
+
* @param request Request.
|
|
127
|
+
* @param response Response.
|
|
128
|
+
* @returns Promise.
|
|
129
|
+
*/
|
|
130
|
+
async #onIncomingRequest(
|
|
131
|
+
request: Http2.Http2ServerRequest,
|
|
132
|
+
response: Http2.Http2ServerResponse
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
const url = new URL(request.url, this.#configuration.server.targetOrigin!);
|
|
135
|
+
const headers: { [name: string]: string } = {};
|
|
136
|
+
|
|
137
|
+
for (const name of Object.keys(request.headers)) {
|
|
138
|
+
if (name[0] !== ':' && name.toLowerCase() !== 'transfer-encoding') {
|
|
139
|
+
headers[name] = Array.isArray(request.headers[name])
|
|
140
|
+
? request.headers[name].join(', ')
|
|
141
|
+
: request.headers[name]!;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const fetchResponse = await fetch(url.href, { headers });
|
|
146
|
+
const isCacheEnabled =
|
|
147
|
+
!this.#configuration.server.disableCache && this.#configuration.server.cacheTime > 0;
|
|
148
|
+
const isCacheQueueEnabled = isCacheEnabled && !this.#configuration.server.disableCacheQueue;
|
|
149
|
+
const cacheKey = this.#getCacheKey(url, headers, fetchResponse.status);
|
|
150
|
+
let result: IServerRendererResult | null = null;
|
|
151
|
+
|
|
152
|
+
response.statusCode = fetchResponse.status;
|
|
153
|
+
|
|
154
|
+
// HTML files should be served from the server renderer.
|
|
155
|
+
if (
|
|
156
|
+
fetchResponse.headers.get('content-type')?.startsWith('text/html') &&
|
|
157
|
+
fetchResponse.status === 200
|
|
158
|
+
) {
|
|
159
|
+
if (isCacheEnabled) {
|
|
160
|
+
const cached = this.#cache.get(cacheKey);
|
|
161
|
+
if (cached) {
|
|
162
|
+
if (Date.now() - cached.timestamp < this.#configuration.server.cacheTime) {
|
|
163
|
+
if (this.#configuration.logLevel >= ServerRendererLogLevelEnum.info) {
|
|
164
|
+
// eslint-disable-next-line no-console
|
|
165
|
+
console.log(Chalk.bold(`• Using cached response for ${url.href}`));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
result = cached.result;
|
|
169
|
+
} else {
|
|
170
|
+
this.#cache.delete(cacheKey);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!result && isCacheQueueEnabled) {
|
|
176
|
+
const cacheQueue = this.#cacheQueue.get(cacheKey);
|
|
177
|
+
if (cacheQueue) {
|
|
178
|
+
if (this.#configuration.logLevel >= ServerRendererLogLevelEnum.info) {
|
|
179
|
+
// eslint-disable-next-line no-console
|
|
180
|
+
console.log(Chalk.bold(`• Waiting for ongoing rendering of ${url.href}`));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
result = await new Promise((resolve, reject) => {
|
|
184
|
+
cacheQueue.push({ resolve, reject });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (this.#configuration.logLevel >= ServerRendererLogLevelEnum.info) {
|
|
188
|
+
// eslint-disable-next-line no-console
|
|
189
|
+
console.log(Chalk.bold(`• Using cached response for ${url.href}`));
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
this.#cacheQueue.set(cacheKey, []);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!result) {
|
|
197
|
+
result = (
|
|
198
|
+
await this.#serverRenderer.render([{ url: url.href, headers }], { keepAlive: true })
|
|
199
|
+
)[0];
|
|
200
|
+
|
|
201
|
+
if (this.#configuration.logLevel >= ServerRendererLogLevelEnum.info) {
|
|
202
|
+
// eslint-disable-next-line no-console
|
|
203
|
+
console.log(Chalk.bold(`• Rendered ${url.href}`));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (isCacheQueueEnabled) {
|
|
208
|
+
const cacheQueue = this.#cacheQueue.get(cacheKey);
|
|
209
|
+
|
|
210
|
+
if (cacheQueue) {
|
|
211
|
+
this.#cacheQueue.delete(cacheKey);
|
|
212
|
+
for (const { resolve } of cacheQueue) {
|
|
213
|
+
resolve(result);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const key of Object.keys(result.headers)) {
|
|
219
|
+
const lowerKey = key.toLowerCase();
|
|
220
|
+
if (
|
|
221
|
+
lowerKey !== 'transfer-encoding' &&
|
|
222
|
+
lowerKey !== 'content-length' &&
|
|
223
|
+
lowerKey !== 'content-encoding'
|
|
224
|
+
) {
|
|
225
|
+
response.setHeader(key, result.headers[key]);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
response.statusCode = result.error ? 500 : result.status;
|
|
230
|
+
|
|
231
|
+
if (result.error) {
|
|
232
|
+
if (this.#configuration.logLevel >= ServerRendererLogLevelEnum.error) {
|
|
233
|
+
// eslint-disable-next-line no-console
|
|
234
|
+
console.log(Chalk.red(`\n✖ Failed to render ${url.href}:\n${result.error}\n`));
|
|
235
|
+
}
|
|
236
|
+
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
237
|
+
response.end(
|
|
238
|
+
`<h1>Internal Server Error</h1><br><p>${result.error.replace(/\n/gm, '<br>')}</p>`
|
|
239
|
+
);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (isCacheEnabled) {
|
|
244
|
+
this.#cache.set(cacheKey, {
|
|
245
|
+
timestamp: Date.now(),
|
|
246
|
+
result
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
response.setHeader('Content-Encoding', 'gzip');
|
|
251
|
+
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
await Stream.pipeline(result.content!, ZLib.createGzip(), response);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (this.#configuration.logLevel >= ServerRendererLogLevelEnum.error) {
|
|
257
|
+
// eslint-disable-next-line no-console
|
|
258
|
+
console.log(Chalk.red(`\n✖ Failed to send response: ${error}\n`));
|
|
259
|
+
}
|
|
260
|
+
response.statusCode = 500;
|
|
261
|
+
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
262
|
+
response.write('Internal Server Error');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
response.end();
|
|
266
|
+
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const [key, value] of fetchResponse.headers.entries()) {
|
|
271
|
+
if (key.toLowerCase() !== 'transfer-encoding') {
|
|
272
|
+
response.setHeader(key, value);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
response.statusCode = fetchResponse.status;
|
|
277
|
+
|
|
278
|
+
if (fetchResponse.headers.get('Content-Encoding')) {
|
|
279
|
+
response.setHeader('Content-Encoding', 'gzip');
|
|
280
|
+
response.removeHeader('Content-Length');
|
|
281
|
+
try {
|
|
282
|
+
await Stream.pipeline(fetchResponse.body!, ZLib.createGzip(), response);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (this.#configuration.logLevel >= ServerRendererLogLevelEnum.error) {
|
|
285
|
+
// eslint-disable-next-line no-console
|
|
286
|
+
console.log(Chalk.red(`\n✖ Failed to send response: ${error}\n`));
|
|
287
|
+
}
|
|
288
|
+
response.statusCode = 500;
|
|
289
|
+
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
290
|
+
response.write('Internal Server Error');
|
|
291
|
+
}
|
|
292
|
+
} else if (fetchResponse.body) {
|
|
293
|
+
const reader = fetchResponse.body.getReader();
|
|
294
|
+
while (true) {
|
|
295
|
+
const { done, value } = await reader.read();
|
|
296
|
+
if (done) {
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
response.write(value);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
response.end();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Returns the network IP address of the server.
|
|
308
|
+
*
|
|
309
|
+
* @returns The network IP address.
|
|
310
|
+
*/
|
|
311
|
+
#getNetworkIP(): string {
|
|
312
|
+
const interfaces = OS.networkInterfaces();
|
|
313
|
+
for (const interfaceName in interfaces) {
|
|
314
|
+
const networkInterface = interfaces[interfaceName];
|
|
315
|
+
if (networkInterface) {
|
|
316
|
+
for (const address of networkInterface) {
|
|
317
|
+
if (address.family === 'IPv4' && !address.internal) {
|
|
318
|
+
return address.address;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return '';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Returns the cache key for the given request.
|
|
328
|
+
*
|
|
329
|
+
* @param url Request URL.
|
|
330
|
+
* @param headers Request headers.
|
|
331
|
+
* @param statusCode Response status code.
|
|
332
|
+
* @returns The cache key.§
|
|
333
|
+
*/
|
|
334
|
+
#getCacheKey(url: URL, headers: { [key: string]: string }, statusCode: number): string {
|
|
335
|
+
return `${url.href}|${JSON.stringify(headers)}|${statusCode}`;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import ServerRendererBrowser from './ServerRendererBrowser.js';
|
|
2
|
+
import { parentPort, workerData } from 'worker_threads';
|
|
3
|
+
import Inspector from 'node:inspector';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Server renderer worker.
|
|
7
|
+
*/
|
|
8
|
+
export default class ServerRendererWorker {
|
|
9
|
+
/**
|
|
10
|
+
* Connects to the worker.
|
|
11
|
+
*/
|
|
12
|
+
public static async connect(): Promise<void> {
|
|
13
|
+
const { configuration } = workerData;
|
|
14
|
+
|
|
15
|
+
if (configuration.inspect) {
|
|
16
|
+
Inspector.open();
|
|
17
|
+
Inspector.waitForDebugger();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const browser = new ServerRendererBrowser(configuration);
|
|
21
|
+
|
|
22
|
+
parentPort?.on('message', async ({ items }) => {
|
|
23
|
+
const results = await browser.render(items);
|
|
24
|
+
parentPort?.postMessage({ status: 'done', results });
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
ServerRendererWorker.connect();
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import DefaultBrowserSettings from 'happy-dom/lib/browser/DefaultBrowserSettings.js';
|
|
2
|
+
import ServerRendererLogLevelEnum from '../enums/ServerRendererLogLevelEnum.js';
|
|
3
|
+
import type IServerRendererConfiguration from '../types/IServerRendererConfiguration.js';
|
|
4
|
+
import OS from 'os';
|
|
5
|
+
import { BrowserErrorCaptureEnum } from 'happy-dom';
|
|
6
|
+
|
|
7
|
+
export default <IServerRendererConfiguration>{
|
|
8
|
+
browser: { ...DefaultBrowserSettings, errorCapture: BrowserErrorCaptureEnum.processLevel },
|
|
9
|
+
outputDirectory: './happy-dom/render',
|
|
10
|
+
logLevel: ServerRendererLogLevelEnum.info,
|
|
11
|
+
debug: false,
|
|
12
|
+
inspect: false,
|
|
13
|
+
help: false,
|
|
14
|
+
cache: {
|
|
15
|
+
disable: false,
|
|
16
|
+
directory: './happy-dom/cache',
|
|
17
|
+
warmup: false
|
|
18
|
+
},
|
|
19
|
+
worker: {
|
|
20
|
+
disable: false,
|
|
21
|
+
maxConcurrency: Math.max(1, Math.floor(OS.cpus().length / 2))
|
|
22
|
+
},
|
|
23
|
+
render: {
|
|
24
|
+
maxConcurrency: 10,
|
|
25
|
+
timeout: 30000, // 30 seconds
|
|
26
|
+
incognitoContext: false,
|
|
27
|
+
serializableShadowRoots: false,
|
|
28
|
+
allShadowRoots: false,
|
|
29
|
+
excludeShadowRootTags: null,
|
|
30
|
+
disablePolyfills: false
|
|
31
|
+
},
|
|
32
|
+
urls: null,
|
|
33
|
+
server: {
|
|
34
|
+
start: false,
|
|
35
|
+
serverURL: 'https://localhost:3000',
|
|
36
|
+
targetOrigin: null,
|
|
37
|
+
disableCache: false,
|
|
38
|
+
disableCacheQueue: false,
|
|
39
|
+
cacheTime: 60000 // 60 seconds
|
|
40
|
+
}
|
|
41
|
+
};
|