@happy-dom/server-renderer
Version:
Use Happy DOM for server-side rendering (SSR) or as a static site generator (SSG).
263 lines • 11.6 kB
JavaScript
import Http2 from 'http2';
import ServerRendererConfigurationFactory from './utilities/ServerRendererConfigurationFactory.js';
import ServerRenderer from './ServerRenderer.js';
import FetchHTTPSCertificate from 'happy-dom/lib/fetch/certificate/FetchHTTPSCertificate.js';
import ZLib from 'node:zlib';
import Stream from 'node:stream/promises';
import OS from 'node:os';
// eslint-disable-next-line import/no-named-as-default
import Chalk from 'chalk';
import ServerRendererLogLevelEnum from './enums/ServerRendererLogLevelEnum.js';
import PackageVersion from './utilities/PackageVersion.js';
/**
* Server renderer proxy HTTP2 server.
*/
export default class ServerRendererServer {
#configuration;
#serverRenderer;
#server = null;
#cache = new Map();
#cacheQueue = new Map();
/**
* Constructor.
*
* @param configuration Configuration.
*/
constructor(configuration) {
this.#configuration = ServerRendererConfigurationFactory.createConfiguration(configuration);
this.#serverRenderer = new ServerRenderer(this.#configuration);
}
/**
* Starts the server.
*/
async start() {
let url;
try {
url = new URL(this.#configuration.server.serverURL);
}
catch (error) {
throw new Error('Failed to start server. The setting "server.serverURL" is not a valid URL.');
}
if (!this.#configuration.server.targetOrigin) {
throw new Error('Failed to start server. The setting "server.targetOrigin" is not set.');
}
let targetOrigin;
try {
targetOrigin = new URL(this.#configuration.server.targetOrigin);
}
catch (error) {
throw new Error('Failed to start server. The setting "server.targetOrigin" is not a valid URL.');
}
switch (url.protocol) {
case 'http:':
this.#server = Http2.createServer((request, response) => this.#onIncomingRequest(request, response));
break;
case 'https:':
this.#server = Http2.createSecureServer({
key: FetchHTTPSCertificate.key,
cert: FetchHTTPSCertificate.cert
}, (request, response) => this.#onIncomingRequest(request, response));
break;
default:
throw new Error(`Unsupported protocol "${url.protocol}". Only "http:" and "https:" are supported.`);
}
this.#server.listen(url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80);
// eslint-disable-next-line no-console
console.log(Chalk.green(`\nHappy DOM Proxy Server ${await PackageVersion.getVersion()}\n`));
// eslint-disable-next-line no-console
console.log(` ${Chalk.green('➜')} ${Chalk.bold('Local:')} ${Chalk.cyan(`${url.protocol}//localhost:${url.port}/`)}\n ${Chalk.green('➜')} ${Chalk.bold('Network:')} ${Chalk.cyan(`${url.protocol}//${this.#getNetworkIP()}:${url.port}/`)}\n ${Chalk.green('➜')} ${Chalk.bold('Target:')} ${Chalk.cyan(`${targetOrigin.protocol}//${targetOrigin.host}/`)}\n\n ${Chalk.green('➜')} ${Chalk.bold('URL:')} ${Chalk.cyan(`${url.protocol}//localhost:${url.port}${url.pathname}${url.search}${url.hash}`)}\n`);
}
/**
* Stops the server.
*/
async stop() {
if (this.#server) {
this.#server.close();
}
if (this.#serverRenderer) {
await this.#serverRenderer.close();
}
}
/**
* Triggered on incoming request.
*
* @param request Request.
* @param response Response.
* @returns Promise.
*/
async #onIncomingRequest(request, response) {
const url = new URL(request.url, this.#configuration.server.targetOrigin);
const headers = {};
for (const name of Object.keys(request.headers)) {
if (name[0] !== ':' && name.toLowerCase() !== 'transfer-encoding') {
headers[name] = Array.isArray(request.headers[name])
? request.headers[name].join(', ')
: request.headers[name];
}
}
const fetchResponse = await fetch(url.href, { headers });
const isCacheEnabled = !this.#configuration.server.disableCache && this.#configuration.server.cacheTime > 0;
const isCacheQueueEnabled = isCacheEnabled && !this.#configuration.server.disableCacheQueue;
const cacheKey = this.#getCacheKey(url, headers, fetchResponse.status);
let result = null;
response.statusCode = fetchResponse.status;
// HTML files should be served from the server renderer.
if (fetchResponse.headers.get('content-type')?.startsWith('text/html') &&
fetchResponse.status === 200) {
if (isCacheEnabled) {
const cached = this.#cache.get(cacheKey);
if (cached) {
if (Date.now() - cached.timestamp < this.#configuration.server.cacheTime) {
if (this.#configuration.logLevel >= ServerRendererLogLevelEnum.info) {
// eslint-disable-next-line no-console
console.log(Chalk.bold(`• Using cached response for ${url.href}`));
}
result = cached.result;
}
else {
this.#cache.delete(cacheKey);
}
}
}
if (!result && isCacheQueueEnabled) {
const cacheQueue = this.#cacheQueue.get(cacheKey);
if (cacheQueue) {
if (this.#configuration.logLevel >= ServerRendererLogLevelEnum.info) {
// eslint-disable-next-line no-console
console.log(Chalk.bold(`• Waiting for ongoing rendering of ${url.href}`));
}
result = await new Promise((resolve, reject) => {
cacheQueue.push({ resolve, reject });
});
if (this.#configuration.logLevel >= ServerRendererLogLevelEnum.info) {
// eslint-disable-next-line no-console
console.log(Chalk.bold(`• Using cached response for ${url.href}`));
}
}
else {
this.#cacheQueue.set(cacheKey, []);
}
}
if (!result) {
result = (await this.#serverRenderer.render([{ url: url.href, headers }], { keepAlive: true }))[0];
if (this.#configuration.logLevel >= ServerRendererLogLevelEnum.info) {
// eslint-disable-next-line no-console
console.log(Chalk.bold(`• Rendered ${url.href}`));
}
}
if (isCacheQueueEnabled) {
const cacheQueue = this.#cacheQueue.get(cacheKey);
if (cacheQueue) {
this.#cacheQueue.delete(cacheKey);
for (const { resolve } of cacheQueue) {
resolve(result);
}
}
}
for (const key of Object.keys(result.headers)) {
const lowerKey = key.toLowerCase();
if (lowerKey !== 'transfer-encoding' &&
lowerKey !== 'content-length' &&
lowerKey !== 'content-encoding') {
response.setHeader(key, result.headers[key]);
}
}
response.statusCode = result.error ? 500 : result.status;
if (result.error) {
if (this.#configuration.logLevel >= ServerRendererLogLevelEnum.error) {
// eslint-disable-next-line no-console
console.log(Chalk.red(`\n✖ Failed to render ${url.href}:\n${result.error}\n`));
}
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end(`<h1>Internal Server Error</h1><br><p>${result.error.replace(/\n/gm, '<br>')}</p>`);
return;
}
if (isCacheEnabled) {
this.#cache.set(cacheKey, {
timestamp: Date.now(),
result
});
}
response.setHeader('Content-Encoding', 'gzip');
response.setHeader('Content-Type', 'text/html; charset=utf-8');
try {
await Stream.pipeline(result.content, ZLib.createGzip(), response);
}
catch (error) {
if (this.#configuration.logLevel >= ServerRendererLogLevelEnum.error) {
// eslint-disable-next-line no-console
console.log(Chalk.red(`\n✖ Failed to send response: ${error}\n`));
}
response.statusCode = 500;
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
response.write('Internal Server Error');
}
response.end();
return;
}
for (const [key, value] of fetchResponse.headers.entries()) {
if (key.toLowerCase() !== 'transfer-encoding') {
response.setHeader(key, value);
}
}
response.statusCode = fetchResponse.status;
if (fetchResponse.headers.get('Content-Encoding')) {
response.setHeader('Content-Encoding', 'gzip');
response.removeHeader('Content-Length');
try {
await Stream.pipeline(fetchResponse.body, ZLib.createGzip(), response);
}
catch (error) {
if (this.#configuration.logLevel >= ServerRendererLogLevelEnum.error) {
// eslint-disable-next-line no-console
console.log(Chalk.red(`\n✖ Failed to send response: ${error}\n`));
}
response.statusCode = 500;
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
response.write('Internal Server Error');
}
}
else if (fetchResponse.body) {
const reader = fetchResponse.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
response.write(value);
}
}
response.end();
}
/**
* Returns the network IP address of the server.
*
* @returns The network IP address.
*/
#getNetworkIP() {
const interfaces = OS.networkInterfaces();
for (const interfaceName in interfaces) {
const networkInterface = interfaces[interfaceName];
if (networkInterface) {
for (const address of networkInterface) {
if (address.family === 'IPv4' && !address.internal) {
return address.address;
}
}
}
}
return '';
}
/**
* Returns the cache key for the given request.
*
* @param url Request URL.
* @param headers Request headers.
* @param statusCode Response status code.
* @returns The cache key.§
*/
#getCacheKey(url, headers, statusCode) {
return `${url.href}|${JSON.stringify(headers)}|${statusCode}`;
}
}
//# sourceMappingURL=ServerRendererServer.js.map