UNPKG

@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
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