@happy-dom/server-renderer
Version:
Use Happy DOM for server-side rendering (SSR) or as a static site generator (SSG).
271 lines • 11.6 kB
JavaScript
import { Worker } from 'worker_threads';
import ServerRendererLogLevelEnum from './enums/ServerRendererLogLevelEnum.js';
import ServerRendererConfigurationFactory from './utilities/ServerRendererConfigurationFactory.js';
import Path from 'path';
import Inspector from 'node:inspector';
import ServerRendererBrowser from './ServerRendererBrowser.js';
// eslint-disable-next-line import/no-named-as-default
import Chalk from 'chalk';
/**
* Server renderer.
*/
export default class ServerRenderer {
#configuration;
#workerPool = {
busy: [],
free: [],
waiting: []
};
#browser = null;
/**
* Constructor.
*
* @param configuration Configuration.
*/
constructor(configuration) {
this.#configuration = ServerRendererConfigurationFactory.createConfiguration(configuration);
if (this.#configuration.worker.disable && this.#configuration.inspect) {
Inspector.open();
Inspector.waitForDebugger();
}
}
/**
* Renders URLs.
*
* @param [urls] URLs to render.
* @param [options] Options.
* @param [options.keepAlive] Keep the workers and browser alive. This is useful when using the renderer in a server. The workers can be closed with the `close()` method.
*/
async render(urls, options) {
const startTime = performance.now();
const configuration = this.#configuration;
const items = urls || configuration.urls;
const parsedItems = [];
if (!items || !items.length) {
if (configuration.logLevel >= ServerRendererLogLevelEnum.info) {
// eslint-disable-next-line no-console
console.log(Chalk.bold(`\nNo URLs to render\n`));
}
return [];
}
for (const item of items) {
if (typeof item === 'string') {
parsedItems.push({ url: item });
}
else {
parsedItems.push({
url: item.url,
outputFile: item.outputFile
? Path.join(configuration.outputDirectory, item.outputFile)
: null,
headers: item.headers ?? null
});
}
}
if (configuration.logLevel >= ServerRendererLogLevelEnum.info) {
if (configuration.debug) {
// eslint-disable-next-line no-console
console.log(Chalk.blue(Chalk.bold(`🔨 Debug mode enabled\n`)));
}
// eslint-disable-next-line no-console
console.log(Chalk.bold(`Rendering ${parsedItems.length} page${parsedItems.length > 1 ? 's' : ''}...\n`));
}
let results = [];
if (!configuration.cache.disable && configuration.cache.warmup) {
const item = parsedItems.shift();
if (item) {
if (configuration.logLevel >= ServerRendererLogLevelEnum.info) {
// eslint-disable-next-line no-console
console.log('Warming up cache...\n');
}
results = results.concat(await this.#runInWorker([item]));
if (configuration.logLevel >= ServerRendererLogLevelEnum.info) {
// eslint-disable-next-line no-console
console.log('\nCache warmup complete.\n');
}
}
}
if (parsedItems.length) {
const promises = [];
while (parsedItems.length) {
const chunk = parsedItems.splice(0, configuration.render.maxConcurrency);
promises.push(this.#runInWorker(chunk).then((chunkResults) => {
results = results.concat(chunkResults);
}));
}
await Promise.all(promises);
}
if (configuration.logLevel >= ServerRendererLogLevelEnum.info) {
const time = Math.round((performance.now() - startTime) / 1000);
const minutes = Math.floor(time / 60);
const seconds = time % 60;
// eslint-disable-next-line no-console
console.log(Chalk.bold(`\nRendered ${items.length} page${items.length > 1 ? 's' : ''} in ${minutes ? `${minutes} minutes and ` : ''}${seconds} seconds\n`));
}
if (!options?.keepAlive) {
await this.close();
}
return results;
}
/**
* Closes the workers and browser.
*/
async close() {
for (const worker of this.#workerPool.busy) {
worker.terminate();
}
for (const worker of this.#workerPool.free) {
worker.terminate();
}
this.#workerPool.busy = [];
this.#workerPool.free = [];
this.#workerPool.waiting = [];
if (this.#browser) {
await this.#browser.close();
this.#browser = null;
}
}
/**
* Runs in a worker.
*
* @param items Items.
*/
async #runInWorker(items) {
const configuration = this.#configuration;
if (configuration.worker.disable) {
if (!this.#browser) {
this.#browser = new ServerRendererBrowser(configuration);
}
const results = await this.#browser.render(items);
this.outputResults(results);
return results;
}
return new Promise((resolve, reject) => {
if (this.#workerPool.free.length === 0) {
const maxConcurrency = configuration.inspect ? 1 : configuration.worker.maxConcurrency;
if (this.#workerPool.busy.length >= maxConcurrency) {
this.#workerPool.waiting.push({ items, resolve, reject });
return;
}
const worker = new Worker(new URL('ServerRendererWorker.js', import.meta.url), {
execArgv: ['--disallow-code-generation-from-strings', '--frozen-intrinsics'],
workerData: {
configuration: configuration
}
});
this.#workerPool.free.push(worker);
}
if (this.#workerPool.free.length > 0) {
const worker = this.#workerPool.free.pop();
this.#workerPool.busy.push(worker);
const done = () => {
worker.off('message', listeners.message);
worker.off('error', listeners.error);
worker.off('exit', listeners.exit);
this.#workerPool.busy.splice(this.#workerPool.busy.indexOf(worker), 1);
this.#workerPool.free.push(worker);
const waiting = this.#workerPool.waiting.shift();
if (waiting) {
this.#runInWorker(waiting.items).then(waiting.resolve).catch(waiting.reject);
}
};
const listeners = {
message: (data) => {
const results = data.results;
this.outputResults(results);
done();
resolve(results);
},
error: (error) => {
if (configuration.logLevel >= ServerRendererLogLevelEnum.error) {
for (const item of items) {
// eslint-disable-next-line no-console
console.error(Chalk.bold(Chalk.red(`\n❌ Failed to render page "${item.url}"\n`)));
// eslint-disable-next-line no-console
console.error(Chalk.red(error + '\n'));
}
}
done();
resolve(items.map((item) => ({
url: item.url,
content: null,
status: 200,
statusText: 'OK',
headers: {},
outputFile: item.outputFile ?? null,
error: `${error.message}\n${error.stack}`,
pageConsole: '',
pageErrors: []
})));
},
exit: (code) => {
// Closed intentionally, either by the user or with terminate()
if (code === 0) {
return;
}
this.#workerPool.busy.splice(this.#workerPool.busy.indexOf(worker), 1);
for (const worker of this.#workerPool.free) {
worker.terminate();
}
for (const worker of this.#workerPool.busy) {
worker.terminate();
}
this.#workerPool.busy = [];
this.#workerPool.free = [];
this.#workerPool.waiting = [];
reject(new Error(`Worker stopped with exit code ${code}`));
}
};
worker.on('message', listeners.message);
worker.on('error', listeners.error);
worker.on('exit', listeners.exit);
worker.postMessage({ items });
}
});
}
/**
* Outputs results to the console.
*
* @param results Results.
*/
outputResults(results) {
const configuration = this.#configuration;
if (configuration.logLevel === ServerRendererLogLevelEnum.none) {
return;
}
for (const result of results) {
if (result.error) {
if (configuration.logLevel >= ServerRendererLogLevelEnum.error) {
// eslint-disable-next-line no-console
console.error(Chalk.bold(Chalk.red(`\n❌ Failed to render page "${result.url}"\n`)));
// eslint-disable-next-line no-console
console.error(Chalk.red(result.error + '\n'));
}
}
else if (result.pageErrors.length) {
if (configuration.logLevel >= ServerRendererLogLevelEnum.warn) {
// eslint-disable-next-line no-console
console.log(Chalk.bold(`• Rendered page "${result.url}"`));
// eslint-disable-next-line no-console
console.log(Chalk.bold(Chalk.yellow(`\n⚠️ Warning! Errors where outputted to the browser when the page was rendered.\n`)));
// eslint-disable-next-line no-console
for (const error of result.pageErrors) {
// eslint-disable-next-line no-console
console.log(Chalk.red(error + '\n'));
}
}
}
else {
if (configuration.logLevel >= ServerRendererLogLevelEnum.info) {
// eslint-disable-next-line no-console
console.log(Chalk.bold(`• Rendered page "${result.url}"`));
}
}
if (configuration.logLevel >= ServerRendererLogLevelEnum.debug && result.pageConsole) {
// eslint-disable-next-line no-console
console.log(Chalk.gray(result.pageConsole));
}
}
}
}
//# sourceMappingURL=ServerRenderer.js.map