highcharts-export-server
Version:
Convert Highcharts.JS charts into static image files.
432 lines (376 loc) • 11.9 kB
JavaScript
/*******************************************************************************
Highcharts Export Server
Copyright (c) 2016-2024, Highsoft
Licenced under the MIT licence.
Additionally a valid Highcharts license is required for use.
See LICENSE file in root for details.
*******************************************************************************/
import { readFileSync } from 'fs';
import path from 'path';
import puppeteer from 'puppeteer';
import { getCachePath } from './cache.js';
import { getOptions } from './config.js';
import { setupHighcharts } from './highcharts.js';
import { log, logWithStack } from './logger.js';
import { __dirname } from './utils.js';
import ExportError from './errors/ExportError.js';
// Get the template for the page
const template = readFileSync(__dirname + '/templates/template.html', 'utf8');
let browser;
/**
* Retrieves the existing Puppeteer browser instance.
*
* @returns {Promise<object>} A Promise resolving to the Puppeteer browser
* instance.
*
* @throws {ExportError} Throws an ExportError if no valid browser has been
* created.
*/
export function get() {
if (!browser) {
throw new ExportError('[browser] No valid browser has been created.');
}
return browser;
}
/**
* Creates a Puppeteer browser instance with the specified arguments.
*
* @param {Array} puppeteerArgs - Additional arguments for Puppeteer launch.
*
* @returns {Promise<object>} A Promise resolving to the Puppeteer browser
* instance.
*
* @throws {ExportError} Throws an ExportError if max retries to open a browser
* instance are reached, or if no browser instance is found after retries.
*/
export async function create(puppeteerArgs) {
// Get debug and other options
const { debug, other } = getOptions();
// Get the debug options
const { enable: enabledDebug, ...debugOptions } = debug;
const launchOptions = {
headless: other.browserShellMode ? 'shell' : true,
userDataDir: './tmp/',
args: puppeteerArgs,
handleSIGINT: false,
handleSIGTERM: false,
handleSIGHUP: false,
waitForInitialPage: false,
defaultViewport: null,
...(enabledDebug && debugOptions)
};
// Create a browser
if (!browser) {
let tryCount = 0;
const open = async () => {
try {
log(
3,
`[browser] Attempting to get a browser instance (try ${++tryCount}).`
);
browser = await puppeteer.launch(launchOptions);
} catch (error) {
logWithStack(
1,
error,
'[browser] Failed to launch a browser instance.'
);
// Retry to launch browser until reaching max attempts
if (tryCount < 25) {
log(3, `[browser] Retry to open a browser (${tryCount} out of 25).`);
await new Promise((response) => setTimeout(response, 4000));
await open();
} else {
throw error;
}
}
};
try {
await open();
// Shell mode inform
if (launchOptions.headless === 'shell') {
log(3, `[browser] Launched browser in shell mode.`);
}
// Debug mode inform
if (enabledDebug) {
log(3, `[browser] Launched browser in debug mode.`);
}
} catch (error) {
throw new ExportError(
'[browser] Maximum retries to open a browser instance reached.'
).setError(error);
}
if (!browser) {
throw new ExportError('[browser] Cannot find a browser to open.');
}
}
// Return a browser promise
return browser;
}
/**
* Closes the Puppeteer browser instance if it is connected.
*
* @returns {Promise<boolean>} A Promise resolving to true after the browser
* is closed.
*/
export async function close() {
// Close the browser when connnected
if (browser?.connected) {
await browser.close();
}
log(4, '[browser] Closed the browser.');
}
/**
* Creates a new Puppeteer Page within an existing browser instance.
*
* If the browser instance is not available, returns false.
*
* The function creates a new page, disables caching, sets content using
* setPageContent(), and returns the created Puppeteer Page.
*
* @returns {(boolean|object)} Returns false if the browser instance is not
* available, or a Puppeteer Page object representing the newly created page.
*/
export async function newPage() {
if (!browser) {
return false;
}
// Create a page
const page = await browser.newPage();
// Disable cache
await page.setCacheEnabled(false);
// Set the content
await setPageContent(page);
// Set page events
setPageEvents(page);
return page;
}
/**
* Clears the content of a Puppeteer Page based on the specified mode.
*
* @param {Object} page - The Puppeteer Page object to be cleared.
* @param {boolean} hardReset - A flag indicating the type of clearing
* to be performed. If true, navigates to 'about:blank' and resets content
* and scripts. If false, clears the body content by setting a predefined HTML
* structure.
*
* @throws {Error} Logs thrown error if clearing the page content fails.
*/
export async function clearPage(page, hardReset = false) {
try {
if (!page.isClosed()) {
if (hardReset) {
// Navigate to about:blank
await page.goto('about:blank', { waitUntil: 'domcontentloaded' });
// Set the content and and scripts again
await setPageContent(page);
} else {
// Clear body content
await page.evaluate(() => {
document.body.innerHTML =
'<div id="chart-container"><div id="container"></div></div>';
});
}
}
} catch (error) {
logWithStack(
2,
error,
'[browser] Could not clear the content of the page.'
);
}
}
/**
* Adds custom JS and CSS resources to a Puppeteer Page based on the specified
* options.
*
* @param {Object} page - The Puppeteer Page object to which resources will be
* added.
* @param {Object} options - All options and configuration.
*
* @returns {Promise<Array<Object>>} - Promise resolving to an array of injected
* resources.
*/
export async function addPageResources(page, options) {
// Injected resources array
const injectedResources = [];
// Use resources
const resources = options.customLogic.resources;
if (resources) {
const injectedJs = [];
// Load custom JS code
if (resources.js) {
injectedJs.push({
content: resources.js
});
}
// Load scripts from all custom files
if (resources.files) {
for (const file of resources.files) {
const isLocal = !file.startsWith('http') ? true : false;
// Add each custom script from resources' files
injectedJs.push(
isLocal
? {
content: readFileSync(file, 'utf8')
}
: {
url: file
}
);
}
}
for (const jsResource of injectedJs) {
try {
injectedResources.push(await page.addScriptTag(jsResource));
} catch (error) {
logWithStack(2, error, `[export] The JS resource cannot be loaded.`);
}
}
injectedJs.length = 0;
// Load CSS
const injectedCss = [];
if (resources.css) {
let cssImports = resources.css.match(/@import\s*([^;]*);/g);
if (cssImports) {
// Handle css section
for (let cssImportPath of cssImports) {
if (cssImportPath) {
cssImportPath = cssImportPath
.replace('url(', '')
.replace('@import', '')
.replace(/"/g, '')
.replace(/'/g, '')
.replace(/;/, '')
.replace(/\)/g, '')
.trim();
// Add each custom css from resources
if (cssImportPath.startsWith('http')) {
injectedCss.push({
url: cssImportPath
});
} else if (options.customLogic.allowFileResources) {
injectedCss.push({
path: path.join(__dirname, cssImportPath)
});
}
}
}
}
// The rest of the CSS section will be content by now
injectedCss.push({
content: resources.css.replace(/@import\s*([^;]*);/g, '') || ' '
});
for (const cssResource of injectedCss) {
try {
injectedResources.push(await page.addStyleTag(cssResource));
} catch (error) {
logWithStack(2, error, `[export] The CSS resource cannot be loaded.`);
}
}
injectedCss.length = 0;
}
}
return injectedResources;
}
/**
* Clears out all state set on the page with addScriptTag/addStyleTag. Removes
* injected resources and resets CSS and script tags on the page. Additionally,
* it destroys previously existing charts.
*
* @param {Object} page - The Puppeteer Page object from which resources will
* be cleared.
* @param {Array<Object>} injectedResources - Array of injected resources
* to be cleared.
*/
export async function clearPageResources(page, injectedResources) {
for (const resource of injectedResources) {
await resource.dispose();
}
// Destroy old charts after export is done and reset all CSS and script tags
await page.evaluate(() => {
// We are not guaranteed that Highcharts is loaded, e,g, when doing SVG
// exports
if (typeof Highcharts !== 'undefined') {
// eslint-disable-next-line no-undef
const oldCharts = Highcharts.charts;
// Check in any already existing charts
if (Array.isArray(oldCharts) && oldCharts.length) {
// Destroy old charts
for (const oldChart of oldCharts) {
oldChart && oldChart.destroy();
// eslint-disable-next-line no-undef
Highcharts.charts.shift();
}
}
}
// eslint-disable-next-line no-undef
const [...scriptsToRemove] = document.getElementsByTagName('script');
// eslint-disable-next-line no-undef
const [, ...stylesToRemove] = document.getElementsByTagName('style');
// eslint-disable-next-line no-undef
const [...linksToRemove] = document.getElementsByTagName('link');
// Remove tags
for (const element of [
...scriptsToRemove,
...stylesToRemove,
...linksToRemove
]) {
element.remove();
}
});
}
/**
* Sets the content for a Puppeteer Page using a predefined template
* and additional scripts. Also, sets the pageerror in order to catch
* and display errors from the window context.
*
* @param {Object} page - The Puppeteer Page object for which the content
* is being set.
*/
async function setPageContent(page) {
await page.setContent(template, { waitUntil: 'domcontentloaded' });
// Add all registered Higcharts scripts, quite demanding
await page.addScriptTag({ path: `${getCachePath()}/sources.js` });
// Set the initial animObject
await page.evaluate(setupHighcharts);
}
/**
* Set events for a Puppeteer Page.
*
* @param {Object} page - The Puppeteer Page object to set events to.
*/
function setPageEvents(page) {
// Get debug options
const { debug } = getOptions();
// Set the console listener, if needed
if (debug.enable && debug.listenToConsole) {
page.on('console', (message) => {
console.log(`[debug] ${message.text()}`);
});
}
// Set the pageerror listener
page.on('pageerror', async (error) => {
// TODO: Consider adding a switch here that turns on log(0) logging
// on page errors.
await page.$eval(
'#container',
(element, errorMessage) => {
// eslint-disable-next-line no-undef
if (window._displayErrors) {
element.innerHTML = errorMessage;
}
},
`<h1>Chart input data error: </h1>${error.toString()}`
);
});
}
export default {
get,
create,
close,
newPage,
clearPage,
addPageResources,
clearPageResources
};