highcharts-export-server
Version:
Convert Highcharts.JS charts into static image files.
407 lines (353 loc) • 12.1 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.
*******************************************************************************/
// The cache manager manages the Highcharts library and its dependencies.
// The cache itself is stored in .cache, and is checked by the config system
// before starting the service
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { getOptions } from './config.js';
import { envs } from './envs.js';
import { fetch } from './fetch.js';
import { log } from './logger.js';
import { __dirname } from './utils.js';
import ExportError from './errors/ExportError.js';
const cache = {
cdnURL: 'https://code.highcharts.com/',
activeManifest: {},
sources: '',
hcVersion: ''
};
/**
* Extracts and caches the Highcharts version from the sources string.
*
* @returns {string} The extracted Highcharts version.
*/
export const extractVersion = (cache) => {
return cache.sources
.substring(0, cache.sources.indexOf('*/'))
.replace('/*', '')
.replace('*/', '')
.replace(/\n/g, '')
.trim();
};
/**
* Extracts the Highcharts module name based on the scriptPath.
*/
export const extractModuleName = (scriptPath) => {
return scriptPath.replace(
/(.*)\/|(.*)modules\/|stock\/(.*)indicators\/|maps\/(.*)modules\//gi,
''
);
};
/**
* Saves the provided configuration and fetched modules to the cache manifest
* file.
*
* @param {object} config - Highcharts-related configuration object.
* @param {object} fetchedModules - An object that contains mapped names of
* fetched Highcharts modules to use.
*
* @throws {ExportError} Throws an ExportError if an error occurs while writing
* the cache manifest.
*/
export const saveConfigToManifest = async (config, fetchedModules) => {
const newManifest = {
version: config.version,
modules: fetchedModules || {}
};
// Update cache object with the current modules
cache.activeManifest = newManifest;
log(3, '[cache] Writing a new manifest.');
try {
writeFileSync(
join(__dirname, config.cachePath, 'manifest.json'),
JSON.stringify(newManifest),
'utf8'
);
} catch (error) {
throw new ExportError('[cache] Error writing the cache manifest.').setError(
error
);
}
};
/**
* Fetches a single script and updates the fetchedModules accordingly.
*
* @param {string} script - A path to script to get.
* @param {Object} requestOptions - Additional options for the proxy agent
* to use for a request.
* @param {Object} fetchedModules - An object which tracks which Highcharts
* modules have been fetched.
* @param {boolean} shouldThrowError - A flag to indicate if the error should be
* thrown. This should be used only for the core scripts.
*
* @returns {Promise<string>} A Promise resolving to the text representation
* of the fetched script.
*
* @throws {ExportError} Throws an ExportError if there is a problem with
* fetching the script.
*/
export const fetchAndProcessScript = async (
script,
requestOptions,
fetchedModules,
shouldThrowError = false
) => {
// Get rid of the .js from the custom strings
if (script.endsWith('.js')) {
script = script.substring(0, script.length - 3);
}
log(4, `[cache] Fetching script - ${script}.js`);
// Fetch the script
const response = await fetch(`${script}.js`, requestOptions);
// If OK, return its text representation
if (response.statusCode === 200 && typeof response.text == 'string') {
if (fetchedModules) {
const moduleName = extractModuleName(script);
fetchedModules[moduleName] = 1;
}
return response.text;
}
if (shouldThrowError) {
throw new ExportError(
`Could not fetch the ${script}.js. The script might not exist in the requested version (status code: ${response.statusCode}).`
).setError(response);
} else {
log(
2,
`[cache] Could not fetch the ${script}.js. The script might not exist in the requested version.`
);
}
return '';
};
/**
* Fetches Highcharts scripts and customScripts from the given CDNs.
*
* @param {string} coreScripts - Array of Highcharts core scripts to fetch.
* @param {string} moduleScripts - Array of Highcharts modules to fetch.
* @param {string} customScripts - Array of custom script paths to fetch
* (full URLs).
* @param {object} proxyOptions - Options for the proxy agent to use for
* a request.
* @param {object} fetchedModules - An object which tracks which Highcharts
* modules have been fetched.
*
* @returns {Promise<string>} The fetched scripts content joined.
*/
export const fetchScripts = async (
coreScripts,
moduleScripts,
customScripts,
proxyOptions,
fetchedModules
) => {
// Configure proxy if exists
let proxyAgent;
const proxyHost = proxyOptions.host;
const proxyPort = proxyOptions.port;
// Try to create a Proxy Agent
if (proxyHost && proxyPort) {
try {
proxyAgent = new HttpsProxyAgent({
host: proxyHost,
port: proxyPort
});
} catch (error) {
throw new ExportError('[cache] Could not create a Proxy Agent.').setError(
error
);
}
}
// If exists, add proxy agent to request options
const requestOptions = proxyAgent
? {
agent: proxyAgent,
timeout: envs.SERVER_PROXY_TIMEOUT
}
: {};
const allFetchPromises = [
...coreScripts.map((script) =>
fetchAndProcessScript(`${script}`, requestOptions, fetchedModules, true)
),
...moduleScripts.map((script) =>
fetchAndProcessScript(`${script}`, requestOptions, fetchedModules)
),
...customScripts.map((script) =>
fetchAndProcessScript(`${script}`, requestOptions)
)
];
const fetchedScripts = await Promise.all(allFetchPromises);
return fetchedScripts.join(';\n');
};
/**
* Updates the local cache with Highcharts scripts and their versions.
*
* @param {Object} options - Object containing all options.
* @param {string} sourcePath - The path to the source file in the cache.
*
* @returns {Promise<object>} A Promise resolving to an object representing
* the fetched modules.
*
* @throws {ExportError} Throws an ExportError if there is an issue updating
* the local Highcharts cache.
*/
export const updateCache = async (
highchartsOptions,
proxyOptions,
sourcePath
) => {
const version = highchartsOptions.version;
const hcVersion = version === 'latest' || !version ? '' : `${version}/`;
const cdnURL = highchartsOptions.cdnURL || cache.cdnURL;
log(
3,
`[cache] Updating cache version to Highcharts: ${hcVersion || 'latest'}.`
);
const fetchedModules = {};
try {
cache.sources = await fetchScripts(
[
...highchartsOptions.coreScripts.map((c) => `${cdnURL}${hcVersion}${c}`)
],
[
...highchartsOptions.moduleScripts.map((m) =>
m === 'map'
? `${cdnURL}maps/${hcVersion}modules/${m}`
: `${cdnURL}${hcVersion}modules/${m}`
),
...highchartsOptions.indicatorScripts.map(
(i) => `${cdnURL}stock/${hcVersion}indicators/${i}`
)
],
highchartsOptions.customScripts,
proxyOptions,
fetchedModules
);
cache.hcVersion = extractVersion(cache);
// Save the fetched modules into caches' source JSON
writeFileSync(sourcePath, cache.sources);
return fetchedModules;
} catch (error) {
throw new ExportError(
'[cache] Unable to update the local Highcharts cache.'
).setError(error);
}
};
/**
* Updates the Highcharts version in the applied configuration and checks
* the cache for the new version.
*
* @param {string} newVersion - The new Highcharts version to be applied.
*
* @returns {Promise<(object|boolean)>} A Promise resolving to the updated
* configuration with the new version, or false if no applied configuration
* exists.
*/
export const updateVersion = async (newVersion) => {
const options = getOptions();
if (options?.highcharts) {
options.highcharts.version = newVersion;
}
await checkAndUpdateCache(options);
};
/**
* Checks the cache for Highcharts dependencies, updates the cache if needed,
* and loads the sources.
*
* @param {Object} options - Object containing all options.
*
* @returns {Promise<void>} A Promise that resolves once the cache is checked
* and updated.
*
* @throws {ExportError} Throws an ExportError if there is an issue updating
* or reading the cache.
*/
export const checkAndUpdateCache = async (options) => {
const { highcharts, server } = options;
const cachePath = join(__dirname, highcharts.cachePath);
let fetchedModules;
// Prepare paths to manifest and sources from the .cache folder
const manifestPath = join(cachePath, 'manifest.json');
const sourcePath = join(cachePath, 'sources.js');
// Create the cache destination if it doesn't exist already
!existsSync(cachePath) && mkdirSync(cachePath);
// Fetch all the scripts either if manifest.json does not exist
// or if the forceFetch option is enabled
if (!existsSync(manifestPath) || highcharts.forceFetch) {
log(3, '[cache] Fetching and caching Highcharts dependencies.');
fetchedModules = await updateCache(highcharts, server.proxy, sourcePath);
} else {
let requestUpdate = false;
// Read the manifest JSON
const manifest = JSON.parse(readFileSync(manifestPath));
// Check if the modules is an array, if so, we rewrite it to a map to make
// it easier to resolve modules.
if (manifest.modules && Array.isArray(manifest.modules)) {
const moduleMap = {};
manifest.modules.forEach((m) => (moduleMap[m] = 1));
manifest.modules = moduleMap;
}
const { coreScripts, moduleScripts, indicatorScripts } = highcharts;
const numberOfModules =
coreScripts.length + moduleScripts.length + indicatorScripts.length;
// Compare the loaded highcharts config with the contents in cache.
// If there are changes, fetch requested modules and products,
// and bake them into a giant blob. Save the blob.
if (manifest.version !== highcharts.version) {
log(
2,
'[cache] A Highcharts version mismatch in the cache, need to re-fetch.'
);
requestUpdate = true;
} else if (Object.keys(manifest.modules || {}).length !== numberOfModules) {
log(
2,
'[cache] The cache and the requested modules do not match, need to re-fetch.'
);
requestUpdate = true;
} else {
// Check each module, if anything is missing refetch everything
requestUpdate = (moduleScripts || []).some((moduleName) => {
if (!manifest.modules[moduleName]) {
log(
2,
`[cache] The ${moduleName} is missing in the cache, need to re-fetch.`
);
return true;
}
});
}
if (requestUpdate) {
fetchedModules = await updateCache(highcharts, server.proxy, sourcePath);
} else {
log(3, '[cache] Dependency cache is up to date, proceeding.');
// Load the sources
cache.sources = readFileSync(sourcePath, 'utf8');
// Get current modules map
fetchedModules = manifest.modules;
cache.hcVersion = extractVersion(cache);
}
}
// Finally, save the new manifest, which is basically our current config
// in a slightly different format
await saveConfigToManifest(highcharts, fetchedModules);
};
export const getCachePath = () =>
join(__dirname, getOptions().highcharts.cachePath);
export const getCache = () => cache;
export const highcharts = () => cache.sources;
export const version = () => cache.hcVersion;
export default {
checkAndUpdateCache,
getCachePath,
updateVersion,
getCache,
highcharts,
version
};