@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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/bin/lib/happy-dom-sr.d.ts +3 -0
  4. package/bin/lib/happy-dom-sr.d.ts.map +1 -0
  5. package/bin/lib/happy-dom-sr.js +25 -0
  6. package/bin/lib/happy-dom-sr.js.map +1 -0
  7. package/bin/src/happy-dom-sr.ts +30 -0
  8. package/lib/ServerRenderer.d.ts +36 -0
  9. package/lib/ServerRenderer.d.ts.map +1 -0
  10. package/lib/ServerRenderer.js +270 -0
  11. package/lib/ServerRenderer.js.map +1 -0
  12. package/lib/ServerRendererBrowser.d.ts +26 -0
  13. package/lib/ServerRendererBrowser.d.ts.map +1 -0
  14. package/lib/ServerRendererBrowser.js +244 -0
  15. package/lib/ServerRendererBrowser.js.map +1 -0
  16. package/lib/ServerRendererServer.d.ts +22 -0
  17. package/lib/ServerRendererServer.d.ts.map +1 -0
  18. package/lib/ServerRendererServer.js +263 -0
  19. package/lib/ServerRendererServer.js.map +1 -0
  20. package/lib/ServerRendererWorker.d.ts +10 -0
  21. package/lib/ServerRendererWorker.d.ts.map +1 -0
  22. package/lib/ServerRendererWorker.js +25 -0
  23. package/lib/ServerRendererWorker.js.map +1 -0
  24. package/lib/config/DefaultServerRendererConfiguration.d.ts +4 -0
  25. package/lib/config/DefaultServerRendererConfiguration.d.ts.map +1 -0
  26. package/lib/config/DefaultServerRendererConfiguration.js +40 -0
  27. package/lib/config/DefaultServerRendererConfiguration.js.map +1 -0
  28. package/lib/enums/ServerRendererLogLevelEnum.d.ts +9 -0
  29. package/lib/enums/ServerRendererLogLevelEnum.d.ts.map +1 -0
  30. package/lib/enums/ServerRendererLogLevelEnum.js +10 -0
  31. package/lib/enums/ServerRendererLogLevelEnum.js.map +1 -0
  32. package/lib/index.d.ts +9 -0
  33. package/lib/index.d.ts.map +1 -0
  34. package/lib/index.js +5 -0
  35. package/lib/index.js.map +1 -0
  36. package/lib/types/IOptionalServerRendererConfiguration.d.ts +108 -0
  37. package/lib/types/IOptionalServerRendererConfiguration.d.ts.map +1 -0
  38. package/lib/types/IOptionalServerRendererConfiguration.js +2 -0
  39. package/lib/types/IOptionalServerRendererConfiguration.js.map +1 -0
  40. package/lib/types/IServerRendererConfiguration.d.ts +108 -0
  41. package/lib/types/IServerRendererConfiguration.d.ts.map +1 -0
  42. package/lib/types/IServerRendererConfiguration.js +2 -0
  43. package/lib/types/IServerRendererConfiguration.js.map +1 -0
  44. package/lib/types/IServerRendererItem.d.ts +8 -0
  45. package/lib/types/IServerRendererItem.d.ts.map +1 -0
  46. package/lib/types/IServerRendererItem.js +2 -0
  47. package/lib/types/IServerRendererItem.js.map +1 -0
  48. package/lib/types/IServerRendererResult.d.ts +14 -0
  49. package/lib/types/IServerRendererResult.d.ts.map +1 -0
  50. package/lib/types/IServerRendererResult.js +2 -0
  51. package/lib/types/IServerRendererResult.js.map +1 -0
  52. package/lib/utilities/BrowserWindowPolyfill.d.ts +13 -0
  53. package/lib/utilities/BrowserWindowPolyfill.d.ts.map +1 -0
  54. package/lib/utilities/BrowserWindowPolyfill.js +92 -0
  55. package/lib/utilities/BrowserWindowPolyfill.js.map +1 -0
  56. package/lib/utilities/HelpPrinter.d.ts +10 -0
  57. package/lib/utilities/HelpPrinter.d.ts.map +1 -0
  58. package/lib/utilities/HelpPrinter.js +32 -0
  59. package/lib/utilities/HelpPrinter.js.map +1 -0
  60. package/lib/utilities/HelpPrinterRows.d.ts +3 -0
  61. package/lib/utilities/HelpPrinterRows.d.ts.map +1 -0
  62. package/lib/utilities/HelpPrinterRows.js +261 -0
  63. package/lib/utilities/HelpPrinterRows.js.map +1 -0
  64. package/lib/utilities/PackageVersion.d.ts +10 -0
  65. package/lib/utilities/PackageVersion.d.ts.map +1 -0
  66. package/lib/utilities/PackageVersion.js +13 -0
  67. package/lib/utilities/PackageVersion.js.map +1 -0
  68. package/lib/utilities/ProcessArgumentsParser.d.ts +20 -0
  69. package/lib/utilities/ProcessArgumentsParser.d.ts.map +1 -0
  70. package/lib/utilities/ProcessArgumentsParser.js +319 -0
  71. package/lib/utilities/ProcessArgumentsParser.js.map +1 -0
  72. package/lib/utilities/ServerRendererConfigurationFactory.d.ts +23 -0
  73. package/lib/utilities/ServerRendererConfigurationFactory.d.ts.map +1 -0
  74. package/lib/utilities/ServerRendererConfigurationFactory.js +82 -0
  75. package/lib/utilities/ServerRendererConfigurationFactory.js.map +1 -0
  76. package/package.json +61 -0
  77. package/src/ServerRenderer.ts +345 -0
  78. package/src/ServerRendererBrowser.ts +286 -0
  79. package/src/ServerRendererServer.ts +337 -0
  80. package/src/ServerRendererWorker.ts +29 -0
  81. package/src/config/DefaultServerRendererConfiguration.ts +41 -0
  82. package/src/enums/ServerRendererLogLevelEnum.ts +9 -0
  83. package/src/index.ts +17 -0
  84. package/src/types/IOptionalServerRendererConfiguration.ts +108 -0
  85. package/src/types/IServerRendererConfiguration.ts +108 -0
  86. package/src/types/IServerRendererItem.ts +5 -0
  87. package/src/types/IServerRendererResult.ts +11 -0
  88. package/src/utilities/BrowserWindowPolyfill.ts +93 -0
  89. package/src/utilities/HelpPrinter.ts +36 -0
  90. package/src/utilities/HelpPrinterRows.ts +260 -0
  91. package/src/utilities/PackageVersion.ts +12 -0
  92. package/src/utilities/ProcessArgumentsParser.ts +294 -0
  93. 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
+ };
@@ -0,0 +1,9 @@
1
+ enum ServerRendererLogLevelEnum {
2
+ none = 0,
3
+ error = 1,
4
+ warn = 2,
5
+ info = 3,
6
+ debug = 4
7
+ }
8
+
9
+ export default ServerRendererLogLevelEnum;